From bf13b1e84fca13e9231818f6ea2dbbe7a92ad5ee Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:37:56 -0600 Subject: [PATCH 01/11] feat: initial Rust Core (cpex-core and cpex-sdk) (#13) * feat: initial revision rust core. Signed-off-by: Teryl Taylor * fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Co-authored-by: Teryl Taylor --- Cargo.lock | 812 ++++++++++++++++ Cargo.toml | 31 + crates/README.md | 209 +++++ crates/cpex-core/Cargo.toml | 27 + crates/cpex-core/src/config.rs | 15 + crates/cpex-core/src/context.rs | 119 +++ crates/cpex-core/src/error.rs | 127 +++ crates/cpex-core/src/executor.rs | 752 +++++++++++++++ crates/cpex-core/src/hooks/adapter.rs | 108 +++ crates/cpex-core/src/hooks/macros.rs | 70 ++ crates/cpex-core/src/hooks/mod.rs | 29 + crates/cpex-core/src/hooks/payload.rs | 174 ++++ crates/cpex-core/src/hooks/trait_def.rs | 272 ++++++ crates/cpex-core/src/hooks/types.rs | 190 ++++ crates/cpex-core/src/lib.rs | 30 + crates/cpex-core/src/manager.rs | 1121 +++++++++++++++++++++++ crates/cpex-core/src/plugin.rs | 414 +++++++++ crates/cpex-core/src/registry.rs | 625 +++++++++++++ crates/cpex-sdk/Cargo.toml | 22 + crates/cpex-sdk/src/lib.rs | 28 + 20 files changed, 5175 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/README.md create mode 100644 crates/cpex-core/Cargo.toml create mode 100644 crates/cpex-core/src/config.rs create mode 100644 crates/cpex-core/src/context.rs create mode 100644 crates/cpex-core/src/error.rs create mode 100644 crates/cpex-core/src/executor.rs create mode 100644 crates/cpex-core/src/hooks/adapter.rs create mode 100644 crates/cpex-core/src/hooks/macros.rs create mode 100644 crates/cpex-core/src/hooks/mod.rs create mode 100644 crates/cpex-core/src/hooks/payload.rs create mode 100644 crates/cpex-core/src/hooks/trait_def.rs create mode 100644 crates/cpex-core/src/hooks/types.rs create mode 100644 crates/cpex-core/src/lib.rs create mode 100644 crates/cpex-core/src/manager.rs create mode 100644 crates/cpex-core/src/plugin.rs create mode 100644 crates/cpex-core/src/registry.rs create mode 100644 crates/cpex-sdk/Cargo.toml create mode 100644 crates/cpex-sdk/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..b06faa5a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,812 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpex-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "futures", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "cpex-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "serde", + "serde_json", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..03fcb104 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +# Location: ./Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Workspace root for the CPEX Rust crates. + +[workspace] +resolver = "2" +members = [ + "crates/cpex-core", + "crates/cpex-sdk", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +authors = ["Teryl Taylor"] + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +serde_json = "1" +async-trait = "0.1" +thiserror = "2" +tracing = "0.1" +uuid = { version = "1", features = ["v4"] } +paste = "1" +futures = "0.3" diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 00000000..62ace2ba --- /dev/null +++ b/crates/README.md @@ -0,0 +1,209 @@ +# CPEX Rust Core + +Phase 1a of the CPEX Rust plugin runtime. Provides the core types, 5-phase executor, and plugin manager for the ContextForge Plugin Extensibility Framework. + +## Status + +Phase 1a — core runtime functional, no language bindings yet. + +- `cpex-core`: Plugin trait, typed hooks, 5-phase executor, plugin manager +- `cpex-sdk`: Lean re-exports for plugin authors + +## Prerequisites + +### Install Rust + +If you don't have Rust installed: + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +Follow the prompts, then restart your shell or run: + +```bash +source $HOME/.cargo/env +``` + +Verify the installation: + +```bash +rustc --version # should be 1.75+ (we develop on 1.94) +cargo --version +``` + +### Update an existing installation + +```bash +rustup update stable +``` + +### Build and test + +From the repository root: + +```bash +# Check that everything compiles +cargo check -p cpex-core -p cpex-sdk + +# Run all tests +cargo test -p cpex-core -p cpex-sdk +``` + +## What It Does + +A typed, 5-phase plugin execution framework where: + +- **Hooks have typed payloads** — no JSON parsing for native Rust plugins +- **Extensions are separate from payloads** — capability-filtered per plugin, modified independently +- **The framework never clones payloads** — handlers receive borrows, clone only when modifying +- **Plugin configs are trusted** — `PluginRef` holds config from the loader, not from the plugin +- **Two invoke paths** — `invoke::()` (typed, Rust) and `invoke_by_name()` (dynamic, Python/Go) + +## Quick Example + +```rust +use std::sync::Arc; +use async_trait::async_trait; +use cpex_core::context::{GlobalContext, PluginContext}; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::hooks::payload::{Extensions, FilteredExtensions}; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig, PluginMode, OnError}; + +// 1. Define a payload +#[derive(Debug, Clone)] +struct ToolCallPayload { + tool_name: String, + include_ssn: bool, +} +cpex_core::impl_plugin_payload!(ToolCallPayload); + +// 2. Define a hook type +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolCallPayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// 3. Write a plugin +struct SsnGuard { config: PluginConfig } + +#[async_trait] +impl Plugin for SsnGuard { + fn config(&self) -> &PluginConfig { &self.config } + async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } +} + +impl HookHandler for SsnGuard { + fn handle( + &self, + payload: &ToolCallPayload, // borrow — zero cost + _extensions: &FilteredExtensions, + _ctx: &PluginContext, + ) -> PluginResult { + if payload.include_ssn { + PluginResult::deny(PluginViolation::new("ssn_denied", "Requires permission")) + } else { + PluginResult::allow() + } + } +} + +// 4. Register and invoke +async fn run() { + let mut manager = PluginManager::default(); + + let config = PluginConfig { + name: "ssn-guard".into(), + kind: "builtin".into(), + hooks: vec!["tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + ..Default::default() + }; + + let plugin = Arc::new(SsnGuard { config: config.clone() }); + manager.register_handler::(plugin, config).unwrap(); + manager.initialize().await.unwrap(); + + let payload = ToolCallPayload { + tool_name: "get_compensation".into(), + include_ssn: true, + }; + + let result = manager + .invoke::(payload, Extensions::default(), &GlobalContext::new("req-1")) + .await; + + assert!(!result.allowed); // denied — SSN access blocked +} +``` + +## Crate Structure + +``` +crates/ +├── cpex-core/src/ +│ ├── lib.rs — module declarations +│ ├── plugin.rs — Plugin trait (lifecycle), PluginConfig, PluginMode, OnError, PluginCondition +│ ├── error.rs — PluginError, PluginViolation +│ ├── context.rs — GlobalContext, PluginContext +│ ├── hooks/ +│ │ ├── payload.rs — PluginPayload trait (object-safe), Extensions, FilteredExtensions +│ │ ├── trait_def.rs — HookTypeDef, HookHandler, PluginResult +│ │ ├── adapter.rs — TypedHandlerAdapter (bridges typed handlers to type-erased dispatch) +│ │ ├── macros.rs — define_hook! macro +│ │ └── types.rs — HookType (string wrapper), hook_names, cmf_hook_names +│ ├── registry.rs — PluginRef (trusted config), PluginRegistry, AnyHookHandler, HookEntry +│ ├── executor.rs — 5-phase engine, PipelineResult, ErasedResultFields +│ ├── manager.rs — PluginManager (register_handler, invoke, invoke_by_name, lifecycle) +│ └── config.rs — (stub — unified YAML parsing, Phase 2) +└── cpex-sdk/src/ + └── lib.rs — lean re-exports for plugin authors +``` + +## 5-Phase Execution Model + +``` +SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET +``` + +| Phase | Can Block? | Can Modify? | Execution | +|-------|------------|-------------|-----------| +| Sequential | Yes | Yes (clone) | Serial, chained | +| Transform | No | Yes (clone) | Serial, chained | +| Audit | No | No | Serial | +| Concurrent | Yes | No | Parallel | +| FireAndForget | No | No | Background | + +All handlers receive `&Payload` (borrow). The framework holds ownership. Modified payloads are returned in `PluginResult::modified_payload` and replace the current payload in the pipeline. + +## Key Design Decisions + +- **PluginRef trust model** — configs come from the config loader, not from `plugin.config()`. Prevents plugins from tampering with their own priority, mode, or capabilities. +- **Borrow-based handlers** — handlers receive `&Payload`, not owned. Framework never clones. Plugins clone only when modifying. Enforced by Rust's borrow checker at compile time. +- **Single `invoke()` path** — one method on `AnyHookHandler`, not separate `invoke_owned`/`invoke_ref`. Simpler API, same behavior. +- **`PluginPayload` trait** — object-safe base for all payloads. `Box` instead of `Box` — type errors caught at compile time. +- **Extensions separate from payload** — capability-filtered per plugin, modified independently. Extension-only changes don't clone the payload. + +## Tests + +```bash +cargo test -p cpex-core -p cpex-sdk +``` + +27 unit tests + 6 doc tests covering: registration, priority ordering, trusted config tamper protection, 5-phase execution, allow/deny/modify results, lifecycle management, typed and dynamic invoke paths. + +## What's Next + +- **Phase 1b**: `cpex-ffi` + Go bindings (first language binding) +- **Phase 1c**: Conformance test corpus (YAML scenarios, Python + Rust) +- **Phase 2**: Unified YAML config parsing +- **Phase 3**: Full CMF extension types (MonotonicSet, Guarded, MetaExtension, etc.) + +See [CPEX Rust Core Proposal](../docs/cpex-rust-core-proposal.md) for the full roadmap. diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml new file mode 100644 index 00000000..4e0d4006 --- /dev/null +++ b/crates/cpex-core/Cargo.toml @@ -0,0 +1,27 @@ +# Location: ./crates/cpex-core/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX Core — pure Rust plugin runtime with no FFI dependencies. +# Contains the PluginManager, 5-phase executor, hook registry, +# config parser, and all core types. + +[package] +name = "cpex-core" +description = "CPEX plugin runtime core — PluginManager, executor, hooks, and config." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_yaml = { workspace = true } +serde_json = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +futures = { workspace = true } diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs new file mode 100644 index 00000000..02496747 --- /dev/null +++ b/crates/cpex-core/src/config.rs @@ -0,0 +1,15 @@ +// Location: ./crates/cpex-core/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Unified YAML configuration parsing. +// +// Parses the unified config format that combines global settings, +// plugin declarations, named policy groups, and per-entity routes +// into a single YAML document. +// +// Mirrors the unified config proposal in +// apl-plugins/docs/unified-config-proposal.md. + +// TODO: Implement CpexConfig, GlobalConfig, RouteEntry serde models diff --git a/crates/cpex-core/src/context.rs b/crates/cpex-core/src/context.rs new file mode 100644 index 00000000..59e61a8a --- /dev/null +++ b/crates/cpex-core/src/context.rs @@ -0,0 +1,119 @@ +// Location: ./crates/cpex-core/src/context.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Execution context types. +// +// Provides PluginContext — the per-plugin, per-invocation execution +// context carrying transient state (counters, caches, intermediate +// results). All data needed for policy evaluation comes from the +// payload's extensions (filtered by capabilities), not from context. +// +// PluginContext has two state maps: +// - local_state: private to this plugin, this invocation +// - global_state: shared across plugins in a pipeline +// +// Identity, request metadata, tenant scope, etc. live in extensions +// (MetaExtension, SecurityExtension), not in the context. +// +// Mirrors the spec's PluginContext in plugin-framework-spec-v2.md §8.1. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// Plugin Context +// --------------------------------------------------------------------------- + +/// Per-plugin, per-invocation execution context. +/// +/// Each plugin receives its own `PluginContext` with: +/// +/// - `local_state` — private to this plugin, this invocation. Fresh +/// each time. Used for per-plugin counters, caches, scratch data. +/// - `global_state` — shared across all plugins in a pipeline. The +/// executor merges changes back after serial phases so subsequent +/// plugins see contributions from earlier ones. +/// +/// All data needed for policy evaluation (identity, tenant, request +/// metadata) comes from the payload's extensions, capability-gated +/// per plugin. Context is purely for transient execution state. +/// +/// ```text +/// PluginContext +/// ├── local_state: HashMap # Per-plugin, per-request. Private. +/// └── global_state: HashMap # Shared across plugins. Use with care. +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginContext { + /// Plugin-local state. Private to this plugin, this invocation. + #[serde(default)] + pub local_state: HashMap, + + /// Shared state across all plugins in the pipeline. + /// The executor merges changes back after each serial-phase plugin. + #[serde(default)] + pub global_state: HashMap, +} + +impl PluginContext { + /// Create a new empty plugin context. + pub fn new() -> Self { + Self { + local_state: HashMap::new(), + global_state: HashMap::new(), + } + } + + /// Create a plugin context with pre-populated global state. + pub fn with_global_state(global_state: HashMap) -> Self { + Self { + local_state: HashMap::new(), + global_state, + } + } + + /// Get a value from local state. + pub fn get_local(&self, key: &str) -> Option<&Value> { + self.local_state.get(key) + } + + /// Set a value in local state. + pub fn set_local(&mut self, key: impl Into, value: Value) { + self.local_state.insert(key.into(), value); + } + + /// Get a value from global state. + pub fn get_global(&self, key: &str) -> Option<&Value> { + self.global_state.get(key) + } + + /// Set a value in global state. + pub fn set_global(&mut self, key: impl Into, value: Value) { + self.global_state.insert(key.into(), value); + } +} + +impl Default for PluginContext { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Plugin Context Table +// --------------------------------------------------------------------------- + +/// Lookup table of `PluginContext` instances indexed by plugin ID. +/// +/// Threaded across hook invocations so that a plugin's `local_state` +/// persists from one hook to the next within the same request lifecycle +/// (e.g., `pre_invoke` → `post_invoke`). +/// +/// The caller receives the table back in `PipelineResult` and passes +/// it into the next hook invocation. On the first hook call, pass +/// `None` — the executor creates fresh contexts for each plugin. +pub type PluginContextTable = HashMap; diff --git a/crates/cpex-core/src/error.rs b/crates/cpex-core/src/error.rs new file mode 100644 index 00000000..4b684d54 --- /dev/null +++ b/crates/cpex-core/src/error.rs @@ -0,0 +1,127 @@ +// Location: ./crates/cpex-core/src/error.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Error types for the CPEX plugin framework. +// +// Provides structured error types for plugin execution failures, +// policy violations, timeouts, and configuration errors. Mirrors +// the Python framework's PluginError, PluginViolation, and +// PluginViolationError types. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +// --------------------------------------------------------------------------- +// Plugin Errors +// --------------------------------------------------------------------------- + +/// Top-level error type for the CPEX framework. +/// +/// Covers plugin execution failures, policy violations, timeouts, +/// and configuration issues. Each variant carries enough context +/// for the caller to log, report, or recover. +#[derive(Debug, Error)] +pub enum PluginError { + /// A plugin raised an execution error. + #[error("plugin '{plugin_name}' failed: {message}")] + Execution { + plugin_name: String, + message: String, + #[source] + source: Option>, + }, + + /// A plugin exceeded its execution timeout. + #[error("plugin '{plugin_name}' timed out after {timeout_ms}ms")] + Timeout { + plugin_name: String, + timeout_ms: u64, + }, + + /// A plugin returned a policy violation (deny). + #[error("plugin '{plugin_name}' denied: {}", violation.reason)] + Violation { + plugin_name: String, + violation: PluginViolation, + }, + + /// Configuration parsing or validation failed. + #[error("configuration error: {message}")] + Config { message: String }, + + /// A hook type was not found in the registry. + #[error("unknown hook type: {hook_type}")] + UnknownHook { hook_type: String }, +} + +// --------------------------------------------------------------------------- +// Plugin Violations +// --------------------------------------------------------------------------- + +/// Structured policy violation returned by a plugin that denies execution. +/// +/// Carries a machine-readable code, human-readable reason, and optional +/// diagnostic details. Corresponds to the Python `PluginViolation` type. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::error::PluginViolation; +/// +/// let v = PluginViolation::new("missing_permission", "User lacks pii_access"); +/// assert_eq!(v.code, "missing_permission"); +/// assert_eq!(v.reason, "User lacks pii_access"); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginViolation { + /// Machine-readable violation identifier (e.g., `"missing_permission"`). + pub code: String, + + /// Short human-readable reason for the denial. + pub reason: String, + + /// Optional detailed explanation. + pub description: Option, + + /// Structured diagnostic data for logging or debugging. + pub details: HashMap, + + /// Name of the plugin that produced the violation. + /// Set by the framework after the plugin returns, not by the plugin itself. + pub plugin_name: Option, +} + +impl PluginViolation { + /// Create a new violation with a code and reason. + pub fn new(code: impl Into, reason: impl Into) -> Self { + Self { + code: code.into(), + reason: reason.into(), + description: None, + details: HashMap::new(), + plugin_name: None, + } + } + + /// Attach a detailed description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Attach structured diagnostic details. + pub fn with_details(mut self, details: HashMap) -> Self { + self.details = details; + self + } +} + +impl std::fmt::Display for PluginViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.code, self.reason) + } +} diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs new file mode 100644 index 00000000..4b1188ef --- /dev/null +++ b/crates/cpex-core/src/executor.rs @@ -0,0 +1,752 @@ +// Location: ./crates/cpex-core/src/executor.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// 5-phase plugin execution engine. +// +// Dispatches plugins in strict phase order: +// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET +// +// Each phase has different authority (block/modify) and scheduling +// (serial/parallel/background). The executor reads all scheduling +// decisions from PluginRef.trusted_config — never from the plugin. +// +// Extensions are passed separately from the payload and capability- +// filtered per plugin before dispatch. Extension modifications are +// merged back independently from payload modifications. +// +// Error handling respects the plugin's on_error setting: +// - Fail: propagate error, halt pipeline +// - Ignore: log error, continue pipeline +// - Disable: log error, mark plugin disabled, continue +// +// Mirrors the Python framework's PluginExecutor in +// cpex/framework/manager.py. + +use std::any::Any; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use tokio::time::timeout; +use tracing::{error, warn}; + +use crate::context::{PluginContext, PluginContextTable}; +use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; +use crate::plugin::OnError; +use crate::registry::{group_by_mode, HookEntry}; + +// --------------------------------------------------------------------------- +// Executor Configuration +// --------------------------------------------------------------------------- + +/// Configuration for the executor. +#[derive(Debug, Clone)] +pub struct ExecutorConfig { + /// Maximum execution time per plugin in seconds. + pub timeout_seconds: u64, + + /// Whether to halt on the first deny in concurrent mode. + pub short_circuit_on_deny: bool, +} + +impl Default for ExecutorConfig { + fn default() -> Self { + Self { + timeout_seconds: 30, + short_circuit_on_deny: true, + } + } +} + +// --------------------------------------------------------------------------- +// Pipeline Result +// --------------------------------------------------------------------------- + +/// Aggregate result from a full hook invocation across all phases. +/// +/// Wraps the final payload, extensions, any violation, and the +/// context table. The caller should pass `context_table` into the +/// next hook invocation to preserve per-plugin local state across +/// hooks in the same request lifecycle. +#[derive(Debug)] +pub struct PipelineResult { + /// Whether the pipeline completed without a deny. + pub allowed: bool, + + /// The final payload after all modifications (type-erased). + /// `None` if the pipeline was denied before any modifications. + pub payload: Option>, + + /// The final extensions after all modifications. + pub extensions: Extensions, + + /// The violation that caused a deny, if any. + pub violation: Option, + + /// Plugin contexts indexed by plugin ID. Thread this into the + /// next hook invocation to preserve per-plugin `local_state`. + pub context_table: PluginContextTable, +} + +impl PipelineResult { + /// Pipeline completed — all plugins allowed. + pub fn allowed_with( + payload: Box, + extensions: Extensions, + context_table: PluginContextTable, + ) -> Self { + Self { + allowed: true, + payload: Some(payload), + extensions, + violation: None, + context_table, + } + } + + /// Pipeline was denied by a plugin. + pub fn denied( + violation: crate::error::PluginViolation, + extensions: Extensions, + context_table: PluginContextTable, + ) -> Self { + Self { + allowed: false, + payload: None, + extensions, + violation: Some(violation), + context_table, + } + } +} + +// --------------------------------------------------------------------------- +// Executor +// --------------------------------------------------------------------------- + +/// 5-phase plugin execution engine. +/// +/// Dispatches hooks through the phase pipeline: +/// +/// ```text +/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET +/// ``` +/// +/// The executor is stateless — all state comes from the arguments. +/// One executor instance can serve multiple concurrent hook invocations. +pub struct Executor { + config: ExecutorConfig, +} + +impl Executor { + /// Create a new executor with the given configuration. + pub fn new(config: ExecutorConfig) -> Self { + Self { config } + } + + /// Execute a hook invocation through the 5-phase pipeline. + /// + /// # Arguments + /// + /// * `entries` — HookEntries for this hook, sorted by priority. + /// * `payload` — The typed payload (type-erased as Box). + /// * `extensions` — The full extensions (filtered per plugin before dispatch). + /// * `context_table` — Optional context table from a previous hook invocation. + /// If `None`, fresh contexts are created for each plugin. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload, extensions, violation, + /// and the updated context table for threading into the next hook. + pub async fn execute( + &self, + entries: &[HookEntry], + payload: Box, + extensions: Extensions, + context_table: Option, + ) -> PipelineResult { + let mut ctx_table = context_table.unwrap_or_default(); + + if entries.is_empty() { + return PipelineResult::allowed_with(payload, extensions, ctx_table); + } + + // Group entries by mode (from trusted_config) + let (sequential, transform, audit, concurrent, fire_and_forget) = + group_by_mode(entries); + + let mut current_payload = payload; + let mut current_extensions = extensions; + + // Phase 1: SEQUENTIAL — serial, chained, can block + modify + if let Some(v) = self + .run_serial_phase( + &sequential, + &mut current_payload, + &mut current_extensions, + &mut ctx_table, + true, // can_block + true, // can_modify + "SEQUENTIAL", + ) + .await + { + return PipelineResult::denied(v, current_extensions, ctx_table); + } + + // Phase 2: TRANSFORM — serial, chained, can modify, cannot block + // can_block=false means denials are suppressed (returns None) + self.run_serial_phase( + &transform, + &mut current_payload, + &mut current_extensions, + &mut ctx_table, + false, // can_block + true, // can_modify + "TRANSFORM", + ) + .await; + + // Phase 3: AUDIT — serial, read-only, discard results + self.run_ref_phase(&audit, &*current_payload, ¤t_extensions, &ctx_table, "AUDIT") + .await; + + // Phase 4: CONCURRENT — parallel, can block, cannot modify + if let Some(violation) = self + .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, &ctx_table) + .await + { + return PipelineResult::denied(violation, current_extensions, ctx_table); + } + + // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results + self.spawn_fire_and_forget( + &fire_and_forget, + &*current_payload, + &ctx_table, + ); + + PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) + } + + // ----------------------------------------------------------------------- + // Phase 1 & 2: Serial execution (SEQUENTIAL / TRANSFORM) + // ----------------------------------------------------------------------- + + /// Run a serial phase — plugins execute one at a time, each seeing + /// the (possibly modified) payload from the previous. + /// + /// The framework retains ownership of the payload. Handlers receive + /// a borrow and clone only if they modify. Modified payloads in + /// the result replace the current payload. + /// + /// Each plugin's context is looked up in the context table (preserving + /// `local_state` from previous hooks) or created fresh. After execution, + /// `global_state` changes are merged back so the next plugin sees them. + async fn run_serial_phase( + &self, + entries: &[HookEntry], + payload: &mut Box, + extensions: &mut Extensions, + ctx_table: &mut PluginContextTable, + can_block: bool, + can_modify: bool, + phase_label: &str, + ) -> Option { + // Extract current global state from the table (use last plugin's + // global_state, or start empty). We maintain a running copy that + // gets set on each plugin's context and merged back after. + let mut global_state = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let plugin_id = entry.plugin_ref.id().to_string(); + let on_error = entry.plugin_ref.trusted_config().on_error; + + // Look up existing context (preserves local_state from prior hooks) + // or create a fresh one. Set global_state to the current running copy. + let mut ctx = ctx_table.remove(&plugin_id).unwrap_or_default(); + ctx.global_state = global_state.clone(); + + // TODO: Capability-filter extensions per plugin (Phase 3) + let filtered = FilteredExtensions::default(); + + // Execute with timeout — handler borrows the payload + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + let result = timeout(timeout_dur, entry.handler.invoke(&**payload, &filtered, &mut ctx)) + .await; + + match result { + Ok(Ok(result_box)) => { + if let Some(erased) = extract_erased(result_box) { + // Check deny + if !erased.continue_processing && can_block { + if let Some(mut v) = erased.violation { + v.plugin_name = Some(plugin_name.clone()); + return Some(v); + } + } + + // Accept modifications + if can_modify { + if let Some(mp) = erased.modified_payload { + *payload = mp; + } + if let Some(me) = erased.modified_extensions { + // TODO: Merge with tier validation (Phase 3) + *extensions = me; + } + } + + // Merge global state changes back from the handler. + // The handler received &mut PluginContext and may have + // written to ctx.global_state directly. + if ctx.global_state != global_state { + global_state = ctx.global_state.clone(); + } + } + // If extract failed or no modifications — payload unchanged + } + Ok(Err(e)) => { + error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e); + match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), + ); + v.plugin_name = Some(plugin_name); + return Some(v); + } + OnError::Ignore => {} + OnError::Disable => { + warn!("{} plugin '{}' disabled after error", phase_label, plugin_name); + entry.plugin_ref.disable(); + } + } + } + Err(_) => { + error!("{} plugin '{}' timed out", phase_label, plugin_name); + match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + ); + v.plugin_name = Some(plugin_name); + return Some(v); + } + OnError::Ignore => {} + OnError::Disable => { + warn!("{} plugin '{}' disabled after error", phase_label, plugin_name); + entry.plugin_ref.disable(); + } + } + } + } + + // Store context back into the table (preserves local_state + // for the next hook invocation via the returned context_table). + // Note: global_state merging from plugin writes is deferred — + // handlers currently receive &PluginContext (shared ref) so + // they can't mutate global_state directly. When we add write-back + // (via PluginResult or interior mutability), merge here. + ctx_table.insert(plugin_id, ctx); + } + + None // no denial + } + + // ----------------------------------------------------------------------- + // Phase 3 & 5: Read-only execution (AUDIT / FIRE_AND_FORGET) + // ----------------------------------------------------------------------- + + /// Run a read-only phase — plugins receive &payload, results discarded. + async fn run_ref_phase( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + _extensions: &Extensions, + ctx_table: &PluginContextTable, + phase_label: &str, + ) { + // Read-only phases get a snapshot of global state but don't merge back. + let global_state: HashMap = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let plugin_id = entry.plugin_ref.id(); + let mut ctx = ctx_table + .get(plugin_id) + .cloned() + .map(|mut c| { c.global_state = global_state.clone(); c }) + .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); + let filtered = FilteredExtensions::default(); + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + let result = timeout(timeout_dur, entry.handler.invoke(payload, &filtered, &mut ctx)) + .await; + + match result { + Ok(Ok(_)) => {} // read-only — discard result + Ok(Err(e)) => { + warn!("{} plugin '{}' error (ignored): {}", phase_label, plugin_name, e); + } + Err(_) => { + warn!("{} plugin '{}' timed out (ignored)", phase_label, plugin_name); + } + } + } + } + + // ----------------------------------------------------------------------- + // Phase 4: Concurrent (parallel, fail-fast) + // ----------------------------------------------------------------------- + + /// Run the concurrent phase — plugins execute truly in parallel. + /// Returns the first violation if any plugin denies. + async fn run_concurrent_phase( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + _extensions: &Extensions, + ctx_table: &PluginContextTable, + ) -> Option { + if entries.is_empty() { + return None; + } + + // Clone the payload once so each spawned task can borrow from + // an owned, 'static copy. Each task gets its own Arc'd clone. + let shared_payload: Arc> = + Arc::new(payload.clone_boxed()); + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + // Snapshot global state for all concurrent plugins + let global_state: HashMap = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + // Spawn all handlers concurrently — each task returns just + // the invoke result. We zip outcomes back with entries to + // access PluginRef for disable() without cloning it into the spawn. + let mut handles = Vec::with_capacity(entries.len()); + + for entry in entries { + let handler = Arc::clone(&entry.handler); + let payload_clone = Arc::clone(&shared_payload); + let plugin_id = entry.plugin_ref.id().to_string(); + let mut ctx = ctx_table + .get(&plugin_id) + .cloned() + .map(|mut c| { c.global_state = global_state.clone(); c }) + .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); + let dur = timeout_dur; + + let handle = tokio::spawn(async move { + let filtered = FilteredExtensions::default(); + timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await + }); + + handles.push(handle); + } + + // Collect results — zip with entries for PluginRef access + let outcomes = futures::future::join_all(handles).await; + let mut denials = Vec::new(); + + for (entry, outcome) in entries.iter().zip(outcomes) { + let plugin_name = entry.plugin_ref.name(); + let on_error = entry.plugin_ref.trusted_config().on_error; + + let result = match outcome { + Ok(r) => r, + Err(e) => { + error!("CONCURRENT task panicked: {}", e); + continue; + } + }; + + match result { + Ok(Ok(result_box)) => { + if let Some(erased) = extract_erased(result_box) { + if !erased.continue_processing { + let mut violation = erased.violation.unwrap_or_else(|| { + crate::error::PluginViolation::new( + "concurrent_deny", + format!("Plugin '{}' denied", plugin_name), + ) + }); + violation.plugin_name = Some(plugin_name.to_string()); + if self.config.short_circuit_on_deny { + return Some(violation); + } + denials.push(violation); + } + } + } + Ok(Err(e)) => match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), + ); + v.plugin_name = Some(plugin_name.to_string()); + return Some(v); + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after error", plugin_name); + entry.plugin_ref.disable(); + } + }, + Err(_) => match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + ); + v.plugin_name = Some(plugin_name.to_string()); + return Some(v); + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name); + entry.plugin_ref.disable(); + } + }, + } + } + + // Return first denial if any were collected (non-short-circuit mode) + denials.into_iter().next() + } + + // ----------------------------------------------------------------------- + // Phase 5: Fire-and-Forget (background, no await) + // ----------------------------------------------------------------------- + + /// Spawn fire-and-forget handlers as background tasks. + /// + /// Each handler runs in its own `tokio::spawn` — the pipeline does + /// not wait for them. Errors and timeouts are logged but have no + /// effect on the pipeline result. + fn spawn_fire_and_forget( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + ctx_table: &PluginContextTable, + ) { + if entries.is_empty() { + return; + } + + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + let global_state: HashMap = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let handler = Arc::clone(&entry.handler); + let owned_payload = payload.clone_boxed(); + let mut ctx = PluginContext::with_global_state(global_state.clone()); + let dur = timeout_dur; + + tokio::spawn(async move { + let filtered = FilteredExtensions::default(); + let result = timeout( + dur, + handler.invoke(&*owned_payload, &filtered, &mut ctx), + ) + .await; + + match result { + Ok(Ok(_)) => {} // discard + Ok(Err(e)) => { + warn!("FIRE_AND_FORGET plugin '{}' error (ignored): {}", plugin_name, e); + } + Err(_) => { + warn!("FIRE_AND_FORGET plugin '{}' timed out (ignored)", plugin_name); + } + } + }); + } + } +} + +impl Default for Executor { + fn default() -> Self { + Self::new(ExecutorConfig::default()) + } +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +// SerialResult removed — run_serial_phase now returns Option directly. + +// --------------------------------------------------------------------------- +// Erased Result Extraction +// --------------------------------------------------------------------------- + +/// Common fields extracted from a type-erased PluginResult. +/// +/// Handlers return `Box` which wraps this struct. The +/// executor extracts it via [`extract_erased()`] to read the +/// control flow fields without knowing the concrete payload type. +pub struct ErasedResultFields { + pub continue_processing: bool, + pub modified_payload: Option>, + pub modified_extensions: Option, + pub violation: Option, +} + +/// Extract erased result fields from a type-erased handler result. +/// +/// Takes ownership of the Box — the executor consumes the result. +/// Logs a warning if the downcast fails (indicates a handler returned +/// the wrong type — a framework bug, not a plugin error). +pub fn extract_erased(result: Box) -> Option { + match result.downcast::() { + Ok(b) => Some(*b), + Err(_) => { + warn!("extract_erased: downcast failed — handler returned unexpected type"); + None + } + } +} + +/// Convert a typed `PluginResult

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

, +) -> Box { + Box::new(ErasedResultFields { + continue_processing: result.continue_processing, + modified_payload: result + .modified_payload + .map(|p| Box::new(p) as Box), + modified_extensions: result.modified_extensions, + violation: result.violation, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::payload::PluginPayload; + use crate::hooks::PluginResult; + + #[derive(Debug, Clone)] + struct TestPayload { + value: String, + } + crate::impl_plugin_payload!(TestPayload); + + #[test] + fn test_erase_result_allow() { + let result: PluginResult = PluginResult::allow(); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.violation.is_none()); + assert!(fields.modified_payload.is_none()); + } + + #[test] + fn test_erase_result_deny() { + let result: PluginResult = PluginResult::deny( + crate::error::PluginViolation::new("test", "denied"), + ); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(!fields.continue_processing); + assert_eq!(fields.violation.as_ref().unwrap().code, "test"); + } + + #[test] + fn test_erase_result_modify_payload() { + let result: PluginResult = PluginResult::modify_payload(TestPayload { + value: "modified".into(), + }); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.modified_payload.is_some()); + } + + #[test] + fn test_erase_result_modify_extensions() { + let mut ext = Extensions::default(); + ext.labels.insert("PII".into()); + let result: PluginResult = PluginResult::modify_extensions(ext); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.modified_extensions.is_some()); + assert!(fields.modified_extensions.as_ref().unwrap().labels.contains("PII")); + } + + #[test] + fn test_pipeline_result_allowed() { + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let result = PipelineResult::allowed_with( + payload, + Extensions::default(), + PluginContextTable::new(), + ); + assert!(result.allowed); + assert!(result.payload.is_some()); + assert!(result.violation.is_none()); + } + + #[test] + fn test_pipeline_result_denied() { + let violation = crate::error::PluginViolation::new("test", "denied"); + let result = PipelineResult::denied( + violation, + Extensions::default(), + PluginContextTable::new(), + ); + assert!(!result.allowed); + assert!(result.payload.is_none()); + assert!(result.violation.is_some()); + } + + #[tokio::test] + async fn test_executor_empty_entries() { + let executor = Executor::default(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let result = executor + .execute(&[], payload, Extensions::default(), None) + .await; + assert!(result.allowed); + assert!(result.payload.is_some()); + } +} diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs new file mode 100644 index 00000000..e0339b95 --- /dev/null +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -0,0 +1,108 @@ +// Location: ./crates/cpex-core/src/hooks/adapter.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// TypedHandlerAdapter — bridges typed HookHandler to type-erased +// AnyHookHandler. +// +// This is framework plumbing that plugin authors never see. When a +// plugin is registered via `manager.register_handler::()`, the +// manager creates a TypedHandlerAdapter internally. The adapter +// translates between Box (what the executor passes) +// and the concrete payload type (what the handler expects). + +use std::marker::PhantomData; +use std::sync::Arc; + +use crate::context::PluginContext; +use crate::error::PluginError; +use crate::executor::erase_result; +use crate::hooks::payload::{FilteredExtensions, PluginPayload}; +use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use crate::plugin::Plugin; +use crate::registry::AnyHookHandler; + +// --------------------------------------------------------------------------- +// Typed Handler Adapter +// --------------------------------------------------------------------------- + +/// Adapts a typed `HookHandler` into the type-erased `AnyHookHandler` +/// interface used by the executor. +/// +/// Created automatically by `PluginManager::register_handler()`. Plugin +/// authors never instantiate this directly. +/// +/// # Type Parameters +/// +/// - `H` — the hook type (implements `HookTypeDef`). +/// - `P` — the plugin type (implements `Plugin + HookHandler`). +pub struct TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ + /// The plugin instance. + plugin: Arc

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

) -> Self { + Self { + plugin, + _hook: PhantomData, + } + } +} + +#[async_trait::async_trait] +impl AnyHookHandler for TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ + /// Downcast the type-erased payload to the concrete type and call + /// the plugin's typed `handle()` method. + /// + /// The framework retains ownership of the payload — the handler + /// receives a borrow (`&H::Payload`) and clones only if it needs + /// to modify. The result is erased back to `ErasedResultFields` + /// for the executor. + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + let typed_ref: &H::Payload = payload + .as_any() + .downcast_ref::() + .ok_or_else(|| PluginError::Config { + message: format!( + "payload type mismatch for hook '{}': expected {}", + H::NAME, + std::any::type_name::() + ), + })?; + + let result = self.plugin.handle(typed_ref, extensions, ctx); + let plugin_result: PluginResult = result.into(); + + Ok(erase_result(plugin_result)) + } + + fn hook_type_name(&self) -> &'static str { + H::NAME + } +} diff --git a/crates/cpex-core/src/hooks/macros.rs b/crates/cpex-core/src/hooks/macros.rs new file mode 100644 index 00000000..80012acf --- /dev/null +++ b/crates/cpex-core/src/hooks/macros.rs @@ -0,0 +1,70 @@ +// Location: ./crates/cpex-core/src/hooks/macros.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// define_hook! macro. +// +// Generates a HookTypeDef marker struct and trait implementation +// from a single declaration. This is the primary way to define new +// hooks — both built-in (CMF, tool, prompt) and custom (rate +// limiting, deployment gates, federation sync). +// +// Plugins implement the generic HookHandler trait (from +// trait_def.rs) for the generated marker struct. The handler +// receives a borrowed payload and returns the hook's result type. + +/// Generates a hook type definition and marker struct. +/// +/// # Usage +/// +/// ```rust,ignore +/// define_hook! { +/// /// Doc comment for the hook. +/// MyHook, "my_hook" => { +/// payload: MyPayload, +/// result: PluginResult, +/// } +/// } +/// ``` +/// +/// This generates a marker struct `MyHook` implementing `HookTypeDef`. +/// Plugins handle it by implementing `HookHandler`. +/// +/// # CMF Pattern (one handler, multiple hook names) +/// +/// For CMF hooks where one handler covers multiple hook names: +/// +/// ```rust,ignore +/// define_hook! { +/// /// CMF message evaluation hook. +/// CmfHook, "cmf" => { +/// payload: MessagePayload, +/// result: PluginResult, +/// } +/// } +/// +/// // Register the same handler for multiple names: +/// // manager.register_handler_for_names::(plugin, config, &[ +/// // "cmf.tool_pre_invoke", "cmf.llm_input", ... +/// // ]); +/// ``` +#[macro_export] +macro_rules! define_hook { + ( + $(#[$meta:meta])* + $name:ident, $hook_name:literal => { + payload: $payload:ty, + result: $result:ty $(,)? + } + ) => { + $(#[$meta])* + pub struct $name; + + impl $crate::hooks::trait_def::HookTypeDef for $name { + type Payload = $payload; + type Result = $result; + const NAME: &'static str = $hook_name; + } + }; +} diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs new file mode 100644 index 00000000..7f4d6ce4 --- /dev/null +++ b/crates/cpex-core/src/hooks/mod.rs @@ -0,0 +1,29 @@ +// Location: ./crates/cpex-core/src/hooks/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Hook system. +// +// Provides the core abstractions for defining and dispatching hooks: +// +// - [`HookTypeDef`] — marker trait associating a typed payload + result with a hook name. +// - [`PluginPayload`] — base trait for all hook payloads (mirrors Python's PluginPayload). +// - [`PluginResult`] — result type with separate payload and extension modifications. +// - [`FilteredExtensions`] — capability-gated extension view passed to handlers. +// - [`define_hook!`] — macro for declaring new hook types with handler traits. +// - [`hook_names`] / [`cmf_hook_names`] — string constants for built-in hooks. +// +// Hook types are open — hosts define their own using define_hook! alongside the built-ins. + +pub mod adapter; +pub mod macros; +pub mod payload; +pub mod trait_def; +pub mod types; + +// Re-export core types at the hooks level +pub use adapter::TypedHandlerAdapter; +pub use payload::{Extensions, FilteredExtensions, PluginPayload}; +pub use trait_def::{HookHandler, HookTypeDef, PluginResult}; +pub use types::{builtin_hook_types, hook_type_from_str, HookType}; diff --git a/crates/cpex-core/src/hooks/payload.rs b/crates/cpex-core/src/hooks/payload.rs new file mode 100644 index 00000000..c25f0247 --- /dev/null +++ b/crates/cpex-core/src/hooks/payload.rs @@ -0,0 +1,174 @@ +// Location: ./crates/cpex-core/src/hooks/payload.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// PluginPayload trait and Extensions stub. +// +// PluginPayload is the base trait for all hook payloads, mirroring +// Python's PluginPayload(BaseModel, frozen=True). All payloads in +// the framework implement this trait, giving the executor and +// registry a common bound for type safety. +// +// The trait is object-safe — the executor works with `Box` +// instead of `Box`, catching type errors at compile time. +// Downcasting to concrete types uses the `as_any()` method. +// +// Extensions is the typed container for all message extensions +// (security, delegation, HTTP, meta, etc.). It is always passed +// as a separate parameter to handlers — never inside the payload. +// This allows per-plugin capability filtering and independent +// modification without copying the payload. + +use std::any::Any; +use std::collections::HashMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Extensions (stub — fleshed out in Phase 3 with full CMF types) +// --------------------------------------------------------------------------- + +/// Typed container for all message extensions. +/// +/// Each field corresponds to an extension with an explicit mutability +/// tier enforced by the processing pipeline. Extensions are always +/// passed separately from the payload to handlers. +/// +/// This is a Phase 1 stub with minimal fields. Phase 3 adds the +/// full CMF extension types (SecurityExtension with MonotonicSet, +/// DelegationExtension with scope-narrowing chain, HttpExtension +/// with Guarded, MetaExtension, etc.). +/// +/// Mirrors Python's `cpex.framework.extensions.Extensions`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Extensions { + /// Security labels (monotonic — add-only in the full implementation). + #[serde(default)] + pub labels: std::collections::HashSet, + + /// Custom extensions (mutable — no restrictions). + #[serde(default)] + pub custom: HashMap, +} + +/// Capability-filtered view of Extensions for a specific plugin. +/// +/// Built by the framework before dispatching to each plugin. Fields +/// the plugin hasn't declared capabilities for are `None`. Plugins +/// receive this as a separate parameter — never inside the payload. +/// +/// Phase 1 stub — Phase 3 adds per-field capability gating matching +/// the Python `filter_extensions()` implementation. +#[derive(Debug, Clone, Default)] +pub struct FilteredExtensions { + /// Security labels (visible with `read_labels` capability). + pub labels: Option>, + + /// Custom extensions (always visible). + pub custom: Option>, +} + +// --------------------------------------------------------------------------- +// PluginPayload Trait +// --------------------------------------------------------------------------- + +/// Base trait for all hook payloads. +/// +/// Mirrors Python's `PluginPayload(BaseModel, frozen=True)`. Every +/// payload type in the framework implements this trait. The executor +/// and registry use `Box` (not `Box`) +/// for type-safe dispatch. +/// +/// The trait is **object-safe** — it can be used behind `Box`, `&`, +/// and `Arc` without knowing the concrete type. This is achieved by +/// providing `clone_boxed()` instead of requiring `Clone` directly +/// (which is not object-safe), and `as_any()` / `as_any_mut()` for +/// downcasting to the concrete type when needed. +/// +/// Payloads are: +/// - Cloneable via `clone_boxed()` — the executor uses this for COW +/// when a modifying plugin (Sequential or Transform) needs ownership. +/// - `Send + Sync` — payloads may be shared across threads for +/// Concurrent mode plugins. +/// - `'static` — payloads must be owned types (no borrowed references). +/// +/// Extensions are **not** part of the payload. They are passed as a +/// separate `&FilteredExtensions` parameter to handlers. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::hooks::payload::PluginPayload; +/// +/// #[derive(Debug, Clone)] +/// struct RateLimitPayload { +/// client_id: String, +/// request_count: u64, +/// } +/// +/// impl PluginPayload for RateLimitPayload { +/// fn clone_boxed(&self) -> Box { +/// Box::new(self.clone()) +/// } +/// fn as_any(&self) -> &dyn std::any::Any { self } +/// fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self } +/// } +/// ``` +pub trait PluginPayload: Send + Sync + 'static { + /// Clone this payload into a new `Box`. + /// + /// Used by the executor for copy-on-write: read-only modes borrow + /// the payload, modifying modes receive a clone via this method. + fn clone_boxed(&self) -> Box; + + /// Downcast to a concrete type via `&dyn Any`. + /// + /// Used by typed handler wrappers to recover the concrete payload + /// type from `Box`. + fn as_any(&self) -> &dyn Any; + + /// Downcast to a concrete type via `&mut dyn Any`. + fn as_any_mut(&mut self) -> &mut dyn Any; +} + +impl fmt::Debug for dyn PluginPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("dyn PluginPayload") + } +} + +// --------------------------------------------------------------------------- +// Blanket helper macro for implementing PluginPayload +// --------------------------------------------------------------------------- + +/// Implements `PluginPayload` for a type that is `Clone + Send + Sync + 'static`. +/// +/// Saves boilerplate — instead of writing the three methods manually, +/// just invoke this macro: +/// +/// ``` +/// use cpex_core::impl_plugin_payload; +/// +/// #[derive(Debug, Clone)] +/// struct MyPayload { value: i32 } +/// +/// impl_plugin_payload!(MyPayload); +/// ``` +#[macro_export] +macro_rules! impl_plugin_payload { + ($ty:ty) => { + impl $crate::hooks::payload::PluginPayload for $ty { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + } + }; +} diff --git a/crates/cpex-core/src/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs new file mode 100644 index 00000000..a437c955 --- /dev/null +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -0,0 +1,272 @@ +// Location: ./crates/cpex-core/src/hooks/trait_def.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// HookTypeDef trait and PluginResult type. +// +// Every hook in the CPEX framework is defined by a marker type that +// implements HookTypeDef. This associates a typed PluginPayload and +// PluginResult with a string name used for registry lookup and config. +// +// The hook type does NOT declare an access pattern (read-only vs +// mutating). The plugin's mode (from PluginRef.trusted_config) +// determines scheduling and authority at runtime. Security invariants +// come from the types inside the payload (Arc, MonotonicSet, +// Guarded), not from borrow mechanics. +// +// Extensions are always a separate parameter — never part of the +// payload. This allows capability-filtered views per plugin and +// independent modification of extensions without copying the payload. + +use crate::context::PluginContext; +use crate::error::PluginViolation; +use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; +use crate::plugin::Plugin; + +// --------------------------------------------------------------------------- +// HookTypeDef Trait +// --------------------------------------------------------------------------- + +/// Defines a hook's contract: what goes in and what comes out. +/// +/// Each hook type is a zero-sized marker struct that implements this +/// trait. The framework uses the associated types for compile-time +/// dispatch and the NAME constant for registry lookup. +/// +/// The hook type does **not** declare an access pattern. The plugin's +/// mode (from `PluginRef.trusted_config`) determines whether the +/// executor passes a borrow or a clone: +/// +/// | Mode | Receives | Can Block? | Can Modify? | +/// |-----------------|-----------------|------------|-------------| +/// | Sequential | owned (clone) | Yes | Yes | +/// | Transform | owned (clone) | No | Yes | +/// | Audit | &Payload | No | No | +/// | Concurrent | &Payload | Yes | No | +/// | FireAndForget | &Payload | No | No | +/// +/// # Defining a Hook +/// +/// Use the [`define_hook!`] macro instead of implementing this trait +/// manually — the macro generates the marker struct, the trait impl, +/// and the handler trait in one declaration. +pub trait HookTypeDef: Send + Sync + 'static { + /// The typed payload that handlers receive. + /// Must implement [`PluginPayload`] (Clone + Send + Sync + 'static). + type Payload: PluginPayload; + + /// The typed result that handlers return. + type Result: Send + Sync; + + /// Hook name — used as the registry key and in config YAML. + /// + /// Multiple hook names can map to the same HookTypeDef (the CMF + /// pattern where one handler covers `cmf.tool_pre_invoke`, + /// `cmf.llm_input`, etc.). The primary NAME is used for + /// single-name registration; additional names are registered + /// via `register_for_names()`. + const NAME: &'static str; +} + +// --------------------------------------------------------------------------- +// Hook Handler Trait +// --------------------------------------------------------------------------- + +/// Typed handler for a specific hook type. +/// +/// Plugin authors implement this trait (alongside [`Plugin`]) to handle +/// a specific hook. The type parameter `H` ties the handler to a +/// `HookTypeDef`, ensuring the correct payload and result types at +/// compile time. +/// +/// The framework creates a type-erased adapter internally when you +/// register — you never touch `AnyHookHandler` directly. +/// +/// # Examples +/// +/// ```rust,ignore +/// impl HookHandler for MyPlugin { +/// fn handle( +/// &self, +/// payload: MessagePayload, +/// extensions: &FilteredExtensions, +/// ctx: &PluginContext, +/// ) -> PluginResult { +/// PluginResult::allow() +/// } +/// } +/// +/// // Registration — no AnyHookHandler needed: +/// manager.register_handler::(plugin, config)?; +/// ``` +pub trait HookHandler: Plugin + Send + Sync { + /// Handle the hook invocation. + /// + /// Receives a **borrow** of the typed payload, capability-filtered + /// extensions, and per-invocation context. Returns a typed result. + /// + /// The payload is immutable — Rust's borrow checker prevents + /// modification through `&H::Payload`. To modify, the plugin + /// must `clone()` the payload (or the fields it needs) and return + /// the modified copy in `PluginResult::modify_payload()`. This + /// pushes the clone cost to the plugin that actually needs it — + /// read-only plugins (validators, auditors) never pay for a copy. + fn handle( + &self, + payload: &H::Payload, + extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> H::Result; +} + +// --------------------------------------------------------------------------- +// Plugin Result +// --------------------------------------------------------------------------- + +/// Result returned by a hook handler. +/// +/// Payload and extension modifications are **separate** — this is a +/// core design decision. Extension-only changes (add a label, set a +/// header) don't require copying the payload. The payload is only +/// present in `modified_payload` when message content actually changed. +/// +/// The executor interprets the result based on the plugin's mode: +/// - Sequential/Transform: `modified_payload` and `modified_extensions` are accepted. +/// - Audit/Concurrent/FireAndForget: modifications are discarded. +/// - Sequential/Concurrent: `continue_processing = false` halts the pipeline. +/// - Transform/Audit/FireAndForget: blocks are suppressed. +/// +/// Mirrors Python's `PluginResult[T]` with separate `modified_payload` +/// and `modified_extensions` fields. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::hooks::{PluginPayload, PluginResult}; +/// use cpex_core::error::PluginViolation; +/// +/// // Define a simple payload +/// #[derive(Debug, Clone)] +/// struct TestPayload { value: i32 } +/// cpex_core::impl_plugin_payload!(TestPayload); +/// +/// // Allow — no changes +/// let result: PluginResult = PluginResult::allow(); +/// assert!(result.continue_processing); +/// assert!(result.modified_payload.is_none()); +/// +/// // Deny +/// let result: PluginResult = PluginResult::deny( +/// PluginViolation::new("forbidden", "not allowed") +/// ); +/// assert!(!result.continue_processing); +/// assert!(result.violation.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct PluginResult { + /// Whether the pipeline should continue processing. + /// `false` halts the pipeline (deny). Only respected for + /// Sequential and Concurrent modes. + pub continue_processing: bool, + + /// Modified payload. `None` means no content modification. + /// Only accepted from Sequential and Transform mode plugins. + pub modified_payload: Option

, + + /// Modified extensions. `None` means no extension changes. + /// Merged back by the framework using tier validation + /// (immutable rejected, monotonic superset-checked, etc.). + /// Only accepted from Sequential and Transform mode plugins. + pub modified_extensions: Option, + + /// Policy violation. Present when `continue_processing` is `false`. + pub violation: Option, + + /// Optional metadata from the plugin (telemetry, diagnostics). + /// Not used for scheduling or policy decisions. + pub metadata: Option, +} + +impl PluginResult

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

{ + fn default() -> Self { + Self::allow() + } +} diff --git a/crates/cpex-core/src/hooks/types.rs b/crates/cpex-core/src/hooks/types.rs new file mode 100644 index 00000000..3295d1a6 --- /dev/null +++ b/crates/cpex-core/src/hooks/types.rs @@ -0,0 +1,190 @@ +// Location: ./crates/cpex-core/src/hooks/types.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Hook type definitions. +// +// Hook types are open strings — hosts define hook points appropriate +// to their execution lifecycle. This module provides a newtype wrapper +// for type safety and built-in constants for the common hook points. +// +// The framework does not prescribe a fixed set of hook points. Each +// host places `invoke_hook()` calls at sites appropriate to its +// processing pipeline. The constants below cover the standard +// MCP/CMF lifecycle but hosts may register additional types. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Hook Type +// --------------------------------------------------------------------------- + +/// A named hook point in the host's execution lifecycle. +/// +/// Wraps a string identifier. Hook types are open — hosts register +/// their own alongside the built-in constants. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::hooks::HookType; +/// use cpex_core::hooks::types::hook_names; +/// +/// // Use a built-in name constant +/// let hook = HookType::new(hook_names::TOOL_PRE_INVOKE); +/// assert_eq!(hook.as_str(), "tool_pre_invoke"); +/// +/// // Define a custom hook +/// let custom = HookType::new("generation_pre_call"); +/// assert_eq!(custom.as_str(), "generation_pre_call"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct HookType(String); + +impl HookType { + /// Create a new hook type from a string. + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + /// Return the hook type as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for HookType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From<&str> for HookType { + fn from(s: &str) -> Self { + Self::new(s) + } +} + +impl From for HookType { + fn from(s: String) -> Self { + Self(s) + } +} + +// --------------------------------------------------------------------------- +// Built-in Hook String Constants +// --------------------------------------------------------------------------- +// Canonical string names for built-in hooks. Use these with +// HookType::new() or pass them directly to APIs that accept &str. + +/// Legacy hook names — typed payloads (ToolPreInvokePayload, etc.). +pub mod hook_names { + // Tool lifecycle + pub const TOOL_PRE_INVOKE: &str = "tool_pre_invoke"; + pub const TOOL_POST_INVOKE: &str = "tool_post_invoke"; + + // Prompt lifecycle + pub const PROMPT_PRE_FETCH: &str = "prompt_pre_fetch"; + pub const PROMPT_POST_FETCH: &str = "prompt_post_fetch"; + + // Resource lifecycle + pub const RESOURCE_PRE_FETCH: &str = "resource_pre_fetch"; + pub const RESOURCE_POST_FETCH: &str = "resource_post_fetch"; + + // Identity and delegation + pub const IDENTITY_RESOLVE: &str = "identity_resolve"; + pub const TOKEN_DELEGATE: &str = "token_delegate"; +} + +/// CMF hook names — MessagePayload wrapping a CMF Message. +/// The `cmf.` prefix lets legacy and CMF plugins coexist at the +/// same interception point. The gateway fires both at each event. +pub mod cmf_hook_names { + // Tool lifecycle + pub const TOOL_PRE_INVOKE: &str = "cmf.tool_pre_invoke"; + pub const TOOL_POST_INVOKE: &str = "cmf.tool_post_invoke"; + + // LLM lifecycle (CMF only — no legacy equivalent) + pub const LLM_INPUT: &str = "cmf.llm_input"; + pub const LLM_OUTPUT: &str = "cmf.llm_output"; + + // Prompt lifecycle + pub const PROMPT_PRE_FETCH: &str = "cmf.prompt_pre_fetch"; + pub const PROMPT_POST_FETCH: &str = "cmf.prompt_post_fetch"; + + // Resource lifecycle + pub const RESOURCE_PRE_FETCH: &str = "cmf.resource_pre_fetch"; + pub const RESOURCE_POST_FETCH: &str = "cmf.resource_post_fetch"; +} + +// --------------------------------------------------------------------------- +// Built-in hook type helpers +// --------------------------------------------------------------------------- + +/// Returns all built-in hook types with their canonical string values. +/// +/// Called once during PluginManager initialization to populate the +/// hook registry. Hosts add their own hook types after this. +pub fn builtin_hook_types() -> Vec { + vec![ + // Legacy (typed payloads) + HookType::new("tool_pre_invoke"), + HookType::new("tool_post_invoke"), + HookType::new("prompt_pre_fetch"), + HookType::new("prompt_post_fetch"), + HookType::new("resource_pre_fetch"), + HookType::new("resource_post_fetch"), + HookType::new("identity_resolve"), + HookType::new("token_delegate"), + // CMF (MessagePayload) + HookType::new("cmf.tool_pre_invoke"), + HookType::new("cmf.tool_post_invoke"), + HookType::new("cmf.llm_input"), + HookType::new("cmf.llm_output"), + HookType::new("cmf.prompt_pre_fetch"), + HookType::new("cmf.prompt_post_fetch"), + HookType::new("cmf.resource_pre_fetch"), + HookType::new("cmf.resource_post_fetch"), + ] +} + +/// Look up a hook type by name. Returns the canonical instance if +/// it matches a built-in, otherwise creates a new custom HookType. +pub fn hook_type_from_str(name: &str) -> HookType { + HookType::new(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_type_equality() { + let a = HookType::new("tool_pre_invoke"); + let b = HookType::new("tool_pre_invoke"); + assert_eq!(a, b); + } + + #[test] + fn test_hook_type_display() { + let h = HookType::new("cmf.llm_input"); + assert_eq!(h.to_string(), "cmf.llm_input"); + } + + #[test] + fn test_hook_type_from_str() { + let h: HookType = "custom_hook".into(); + assert_eq!(h.as_str(), "custom_hook"); + } + + #[test] + fn test_builtin_hook_types_count() { + let builtins = builtin_hook_types(); + // 8 legacy + 8 CMF + assert_eq!(builtins.len(), 16); + } +} diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs new file mode 100644 index 00000000..2743b238 --- /dev/null +++ b/crates/cpex-core/src/lib.rs @@ -0,0 +1,30 @@ +// Location: ./crates/cpex-core/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Core library root. +// +// Pure Rust plugin runtime with no FFI, WASM, or PyO3 dependencies. +// Provides the PluginManager, 5-phase executor, hook registry, +// unified config parser, and all core types. +// +// # Modules +// +// - [`plugin`] — Plugin trait, PluginRef, PluginMetadata, PluginConfig +// - [`hooks`] — HookType (open string registry), payload/result traits +// - [`executor`] — 5-phase execution engine (sequential → transform → audit → concurrent → fire_and_forget) +// - [`manager`] — PluginManager lifecycle and hook dispatch +// - [`registry`] — PluginInstanceRegistry and HookRegistry +// - [`config`] — Unified YAML configuration parsing +// - [`context`] — PluginContext (local_state + global_state) +// - [`error`] — Error types, violations, and result types + +pub mod config; +pub mod context; +pub mod error; +pub mod executor; +pub mod hooks; +pub mod manager; +pub mod plugin; +pub mod registry; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs new file mode 100644 index 00000000..a2a6e8a3 --- /dev/null +++ b/crates/cpex-core/src/manager.rs @@ -0,0 +1,1121 @@ +// Location: ./crates/cpex-core/src/manager.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin manager. +// +// Owns the plugin lifecycle (initialize, dispatch, shutdown) and +// the PluginRegistry. Provides two invoke paths: +// +// - `invoke::()` — typed dispatch for Rust callers. Zero-cost. +// The hook type is known at compile time; no registry lookup or +// downcast needed for the payload. +// +// - `invoke_by_name()` — dynamic dispatch for Python/Go/WASM callers. +// Hook name resolved from the registry; payload passed as +// Box. +// +// The manager reads plugin configs from the config loader and wraps +// each plugin in a PluginRef with the authoritative config. Plugins +// never provide their own config to the manager. Trust flows: +// config loader → manager → PluginRef → executor +// +// Mirrors the Python framework's PluginManager in +// cpex/framework/manager.py. + +use std::sync::Arc; + +use tracing::{error, info}; + +use crate::context::PluginContextTable; +use crate::error::PluginError; +use crate::executor::{Executor, ExecutorConfig, PipelineResult}; +use crate::hooks::adapter::TypedHandlerAdapter; +use crate::hooks::payload::{Extensions, PluginPayload}; +use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use crate::hooks::HookType; +use crate::plugin::{Plugin, PluginConfig}; +use crate::registry::{AnyHookHandler, PluginRef, PluginRegistry}; + +// --------------------------------------------------------------------------- +// Manager Configuration +// --------------------------------------------------------------------------- + +/// Configuration for the PluginManager. +#[derive(Debug, Clone)] +pub struct ManagerConfig { + /// Executor configuration (timeout, short-circuit behavior). + pub executor: ExecutorConfig, +} + +impl Default for ManagerConfig { + fn default() -> Self { + Self { + executor: ExecutorConfig::default(), + } + } +} + +// --------------------------------------------------------------------------- +// Plugin Manager +// --------------------------------------------------------------------------- + +/// Central plugin lifecycle and dispatch manager. +/// +/// Owns the plugin registry and executor. Provides the public API +/// that host systems (ContextForge, Kagenti, etc.) call to register +/// plugins and invoke hooks. +/// +/// # Lifecycle +/// +/// ```text +/// new() → register plugins → initialize() → invoke hooks → shutdown() +/// ``` +/// +/// # Two Invoke Paths +/// +/// - **`invoke::()`** — typed dispatch. The hook type `H` is known +/// at compile time. Payload type-checked at compile time. Used by +/// Rust callers. +/// +/// - **`invoke_by_name()`** — dynamic dispatch. The hook name is a +/// string. Payload is `Box`. Used by Python/Go/WASM +/// callers via the FFI or PyO3 bindings. +/// +/// Both paths use the same registry, executor, and 5-phase pipeline. +/// +/// # Trust Model +/// +/// The manager wraps each plugin in a `PluginRef` with an authoritative +/// config from the config loader. The executor reads all scheduling +/// decisions from `PluginRef.trusted_config` — never from the plugin. +pub struct PluginManager { + /// Plugin registry — stores PluginRefs and hook-to-handler mappings. + registry: PluginRegistry, + + /// Executor — stateless 5-phase pipeline engine. + executor: Executor, + + /// Whether initialize() has been called. + initialized: bool, +} + +impl PluginManager { + /// Create a new PluginManager with the given configuration. + pub fn new(config: ManagerConfig) -> Self { + Self { + registry: PluginRegistry::new(), + executor: Executor::new(config.executor), + initialized: false, + } + } + + // ----------------------------------------------------------------------- + // Registration + // ----------------------------------------------------------------------- + + /// Register a plugin handler for its primary hook name. + /// + /// This is the preferred registration method. The framework creates + /// the type-erased adapter internally — no `AnyHookHandler` needed. + /// + /// # Type Parameters + /// + /// - `H` — the hook type (implements `HookTypeDef`). + /// - `P` — the plugin type (implements `Plugin + HookHandler`). + /// + /// # Arguments + /// + /// - `plugin` — the plugin implementation. + /// - `config` — authoritative config from the config loader. + /// + /// # Examples + /// + /// ```rust,ignore + /// manager.register_handler::(plugin, config)?; + /// ``` + pub fn register_handler( + &mut self, + plugin: Arc

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

, + config: PluginConfig, + names: &[&str], + ) -> Result<(), PluginError> + where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, + { + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + self.registry + .register_for_names::(plugin, config, handler, names) + .map_err(|msg| PluginError::Config { message: msg }) + } + + /// Register with an explicit AnyHookHandler (advanced use). + /// + /// For cases where the automatic adapter doesn't fit — e.g., + /// Python/WASM bridge hosts that implement AnyHookHandler directly. + /// Most callers should use `register_handler` instead. + pub fn register_raw( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + ) -> Result<(), PluginError> { + self.registry + .register::(plugin, config, handler) + .map_err(|msg| PluginError::Config { message: msg }) + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + /// Initialize all registered plugins. + /// + /// Calls `plugin.initialize()` on each registered plugin. Must be + /// called before invoking any hooks. Idempotent — calling twice + /// has no effect. + pub async fn initialize(&mut self) -> Result<(), PluginError> { + if self.initialized { + return Ok(()); + } + + info!( + "Initializing PluginManager with {} plugins", + self.registry.plugin_count() + ); + + let mut initialized_plugins: Vec = Vec::new(); + + for name in self.registry.plugin_names() { + if let Some(plugin_ref) = self.registry.get(name) { + let plugin = plugin_ref.plugin().clone(); + let plugin_name = name.to_string(); + + if let Err(e) = plugin.initialize().await { + error!("Failed to initialize plugin '{}': {}", plugin_name, e); + + // Clean up already-initialized plugins + for init_name in initialized_plugins.iter().rev() { + if let Some(pr) = self.registry.get(init_name) { + if let Err(shutdown_err) = pr.plugin().shutdown().await { + error!( + "Error shutting down plugin '{}' during rollback: {}", + init_name, shutdown_err + ); + } + } + } + + return Err(PluginError::Execution { + plugin_name, + message: format!("initialization failed: {}", e), + source: Some(Box::new(e)), + }); + } + + initialized_plugins.push(plugin_name); + } + } + + self.initialized = true; + info!("PluginManager initialized successfully"); + Ok(()) + } + + /// Shutdown all registered plugins. + /// + /// Calls `plugin.shutdown()` on each registered plugin in reverse + /// registration order. Errors are logged but do not halt the + /// shutdown process — all plugins get a chance to clean up. + pub async fn shutdown(&mut self) { + if !self.initialized { + return; + } + + info!("Shutting down PluginManager"); + + for name in self.registry.plugin_names() { + if let Some(plugin_ref) = self.registry.get(name) { + let plugin = plugin_ref.plugin().clone(); + + if let Err(e) = plugin.shutdown().await { + error!("Error shutting down plugin '{}': {}", name, e); + // Continue — don't let one plugin's failure block others + } + } + } + + self.initialized = false; + info!("PluginManager shutdown complete"); + } + + // ----------------------------------------------------------------------- + // Hook Invocation — Dynamic (invoke_by_name) + // ----------------------------------------------------------------------- + + /// Invoke a hook by name with a type-erased payload. + /// + /// This is the dynamic dispatch path used by Python/Go/WASM + /// callers via FFI or PyO3 bindings. The hook name is resolved + /// from the registry and dispatched through the 5-phase executor. + /// + /// # Arguments + /// + /// * `hook_name` — the hook name string (e.g., `"cmf.tool_pre_invoke"`). + /// * `payload` — the payload as `Box`. + /// * `extensions` — the full extensions (filtered per plugin by the executor). + /// * `context_table` — optional context table from a previous hook + /// invocation. Pass `None` on the first hook call; thread the + /// returned table into subsequent calls to preserve per-plugin state. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload, extensions, violation, + /// and the updated context table. + pub async fn invoke_by_name( + &self, + hook_name: &str, + payload: Box, + extensions: Extensions, + context_table: Option, + ) -> PipelineResult { + let hook_type = HookType::new(hook_name); + let entries = self.registry.entries_for_hook(&hook_type); + + if entries.is_empty() { + return PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ); + } + + self.executor + .execute(entries, payload, extensions, context_table) + .await + } + + // ----------------------------------------------------------------------- + // Hook Invocation — Typed (invoke::) + // ----------------------------------------------------------------------- + + /// Invoke a typed hook. + /// + /// This is the compile-time dispatch path used by Rust callers. + /// The hook type `H` determines the payload and result types. + /// Dispatch goes through the same registry and 5-phase executor + /// as `invoke_by_name()`. + /// + /// # Type Parameters + /// + /// - `H` — the hook type (implements `HookTypeDef`). + /// + /// # Arguments + /// + /// * `payload` — the typed payload. + /// * `extensions` — the full extensions. + /// * `context_table` — optional context table from a previous hook. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload (type-erased — + /// caller downcasts via `as_any()`), extensions, violation, and + /// the updated context table. + pub async fn invoke( + &self, + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> PipelineResult { + let hook_type = HookType::new(H::NAME); + let entries = self.registry.entries_for_hook(&hook_type); + + if entries.is_empty() { + let boxed: Box = Box::new(payload); + return PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ); + } + + let boxed: Box = Box::new(payload); + self.executor + .execute(entries, boxed, extensions, context_table) + .await + } + + // ----------------------------------------------------------------------- + // Query Methods + // ----------------------------------------------------------------------- + + /// Whether any plugins are registered for the given hook name. + pub fn has_hooks_for(&self, hook_name: &str) -> bool { + self.registry.has_hooks_for(&HookType::new(hook_name)) + } + + /// Look up a plugin by name. + pub fn get_plugin(&self, name: &str) -> Option<&PluginRef> { + self.registry.get(name) + } + + /// Total number of registered plugins. + pub fn plugin_count(&self) -> usize { + self.registry.plugin_count() + } + + /// All registered plugin names. + pub fn plugin_names(&self) -> Vec<&str> { + self.registry.plugin_names() + } + + /// Whether the manager has been initialized. + pub fn is_initialized(&self) -> bool { + self.initialized + } + + /// Unregister a plugin by name. + pub fn unregister(&mut self, name: &str) -> Option { + self.registry.unregister(name) + } +} + +impl Default for PluginManager { + fn default() -> Self { + Self::new(ManagerConfig::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::PluginContext; + use crate::error::PluginViolation; + use crate::hooks::payload::FilteredExtensions; + use crate::hooks::{HookHandler, PluginResult}; + use crate::plugin::{OnError, PluginMode}; + use async_trait::async_trait; + + // -- Test payload -- + + #[derive(Debug, Clone)] + struct TestPayload { + value: String, + } + crate::impl_plugin_payload!(TestPayload); + + // -- Test hook type -- + + struct TestHook; + impl HookTypeDef for TestHook { + type Payload = TestPayload; + type Result = PluginResult; + const NAME: &'static str = "test_hook"; + } + + // -- Test plugins: implement Plugin + HookHandler -- + // No AnyHookHandler boilerplate — the framework handles it. + + /// Plugin that allows everything. + struct AllowPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for AllowPlugin { + fn config(&self) -> &PluginConfig { &self.cfg } + async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + } + + impl HookHandler for AllowPlugin { + fn handle( + &self, + _payload: &TestPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } + } + + /// Plugin that denies everything. + struct DenyPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for DenyPlugin { + fn config(&self) -> &PluginConfig { &self.cfg } + async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + } + + impl HookHandler for DenyPlugin { + fn handle( + &self, + _payload: &TestPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new("denied", "test denial")) + } + } + + /// Handler that always returns an error (for testing on_error behavior). + struct ErrorHandler; + + #[async_trait] + impl AnyHookHandler for ErrorHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + Err(PluginError::Execution { + plugin_name: "error-plugin".into(), + message: "simulated failure".into(), + source: None, + }) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // -- Helpers -- + + fn make_config(name: &str, priority: i32, mode: PluginMode) -> PluginConfig { + make_config_with_on_error(name, priority, mode, OnError::Fail) + } + + fn make_config_with_on_error( + name: &str, + priority: i32, + mode: PluginMode, + on_error: OnError, + ) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec!["test_hook".to_string()], + mode, + priority, + on_error, + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } + } + + // -- Tests -- + + #[tokio::test] + async fn test_manager_lifecycle() { + let mut mgr = PluginManager::default(); + assert!(!mgr.is_initialized()); + assert_eq!(mgr.plugin_count(), 0); + + mgr.initialize().await.unwrap(); + assert!(mgr.is_initialized()); + + // Idempotent + mgr.initialize().await.unwrap(); + + mgr.shutdown().await; + assert!(!mgr.is_initialized()); + } + + #[tokio::test] + async fn test_invoke_by_name_no_plugins() { + let mgr = PluginManager::default(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.allowed); + assert!(result.payload.is_some()); + } + + #[tokio::test] + async fn test_invoke_by_name_allow() { + let mut mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + + // Clean registration — no AnyHookHandler needed + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.allowed); + } + + #[tokio::test] + async fn test_invoke_by_name_deny() { + let mut mgr = PluginManager::default(); + let config = make_config("deny-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(!result.allowed); + assert_eq!(result.violation.as_ref().unwrap().code, "denied"); + } + + #[tokio::test] + async fn test_invoke_typed() { + let mut mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "typed".into(), + }; + + + let result = mgr + .invoke::(payload, Extensions::default(), None) + .await; + + assert!(result.allowed); + } + + #[tokio::test] + async fn test_has_hooks_for() { + let mut mgr = PluginManager::default(); + assert!(!mgr.has_hooks_for("test_hook")); + + let config = make_config("p1", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + mgr.register_handler::(plugin, config).unwrap(); + + assert!(mgr.has_hooks_for("test_hook")); + assert!(!mgr.has_hooks_for("other_hook")); + } + + #[tokio::test] + async fn test_unregister() { + let mut mgr = PluginManager::default(); + let config = make_config("removable", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + mgr.register_handler::(plugin, config).unwrap(); + + assert_eq!(mgr.plugin_count(), 1); + mgr.unregister("removable"); + assert_eq!(mgr.plugin_count(), 0); + assert!(!mgr.has_hooks_for("test_hook")); + } + + #[tokio::test] + async fn test_audit_plugin_cannot_block() { + let mut mgr = PluginManager::default(); + let config = make_config("audit-denier", 10, PluginMode::Audit); + let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Audit mode — deny is suppressed, pipeline continues + assert!(result.allowed); + } + + #[tokio::test] + async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() { + let mut mgr = PluginManager::default(); + + // Register an error handler with on_error: Disable + let config = make_config_with_on_error( + "flaky-plugin", 10, PluginMode::Sequential, OnError::Disable, + ); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + // Also register a normal allow plugin (lower priority = runs second) + let config2 = make_config("allow-plugin", 20, PluginMode::Sequential); + let plugin2 = Arc::new(AllowPlugin { cfg: config2.clone() }); + mgr.register_handler::(plugin2, config2).unwrap(); + + mgr.initialize().await.unwrap(); + + + // First invocation — flaky plugin errors, gets disabled, pipeline continues + // because on_error is Disable (not Fail). allow-plugin still runs. + let payload: Box = Box::new(TestPayload { value: "first".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(result.allowed); + + // Verify the plugin is now disabled + let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); + assert!(plugin_ref.is_disabled()); + assert_eq!(plugin_ref.mode(), PluginMode::Disabled); + + // Second invocation — flaky plugin should be skipped entirely + // (group_by_mode filters it out). Only allow-plugin runs. + let payload2: Box = Box::new(TestPayload { value: "second".into() }); + let result2 = mgr.invoke_by_name("test_hook", payload2, Extensions::default(), None).await; + assert!(result2.allowed); + } + + #[tokio::test] + async fn test_on_error_ignore_continues_without_disabling() { + let mut mgr = PluginManager::default(); + + // Register an error handler with on_error: Ignore + let config = make_config_with_on_error( + "flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore, + ); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + + // First invocation — plugin errors, ignored, pipeline continues + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(result.allowed); + + // Plugin should NOT be disabled — still in its original mode + let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); + assert!(!plugin_ref.is_disabled()); + assert_eq!(plugin_ref.mode(), PluginMode::Sequential); + } + + #[tokio::test] + async fn test_on_error_fail_halts_pipeline() { + let mut mgr = PluginManager::default(); + + // Register an error handler with on_error: Fail (default) + let config = make_config_with_on_error( + "strict-plugin", 10, PluginMode::Sequential, OnError::Fail, + ); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + + // Invocation — plugin errors, pipeline halts with a violation + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(!result.allowed); + assert_eq!(result.violation.as_ref().unwrap().code, "plugin_error"); + assert_eq!( + result.violation.as_ref().unwrap().plugin_name.as_deref(), + Some("strict-plugin"), + ); + } + + // -- Additional test plugins -- + + /// Plugin that modifies the payload (for Transform mode testing). + struct TransformPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for TransformPlugin { + fn config(&self) -> &PluginConfig { &self.cfg } + async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + } + + impl HookHandler for TransformPlugin { + fn handle( + &self, + payload: &TestPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::modify_payload(TestPayload { + value: format!("{}_transformed", payload.value), + }) + } + } + + /// Handler that sleeps (for timeout and fire-and-forget testing). + struct SlowHandler { + delay_ms: u64, + } + + #[async_trait] + impl AnyHookHandler for SlowHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await; + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // -- Bug-covering tests -- + + #[tokio::test] + async fn test_transform_modifies_payload() { + let mut mgr = PluginManager::default(); + let config = make_config("transformer", 10, PluginMode::Transform); + let plugin = Arc::new(TransformPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { value: "original".into() }; + + let result = mgr.invoke::(payload, Extensions::default(), None).await; + + assert!(result.allowed); + let final_payload = result.payload.unwrap(); + let typed = final_payload.as_any().downcast_ref::().unwrap(); + assert_eq!(typed.value, "original_transformed"); + } + + #[tokio::test] + async fn test_concurrent_multiple_plugins_all_run() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + // Shared counter to prove both plugins actually ran + static CALL_COUNT: AtomicUsize = AtomicUsize::new(0); + CALL_COUNT.store(0, Ordering::SeqCst); + + struct CountingHandler; + + #[async_trait] + impl AnyHookHandler for CountingHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + // Small sleep to ensure both tasks are spawned before either finishes + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + CALL_COUNT.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mut mgr = PluginManager::default(); + + let c1 = make_config("concurrent-1", 10, PluginMode::Concurrent); + let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); + let h1: Arc = Arc::new(CountingHandler); + mgr.register_raw::(p1, c1, h1).unwrap(); + + let c2 = make_config("concurrent-2", 20, PluginMode::Concurrent); + let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); + let h2: Arc = Arc::new(CountingHandler); + mgr.register_raw::(p2, c2, h2).unwrap(); + + mgr.initialize().await.unwrap(); + + let start = std::time::Instant::now(); + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let elapsed = start.elapsed(); + + assert!(result.allowed); + assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2); + // If they ran in parallel, total time should be ~50ms, not ~100ms + assert!(elapsed.as_millis() < 90, "concurrent plugins ran serially: {}ms", elapsed.as_millis()); + } + + #[tokio::test] + async fn test_timeout_fires_on_slow_handler() { + // Create a manager with a very short timeout + let config = ManagerConfig { + executor: crate::executor::ExecutorConfig { + timeout_seconds: 1, + short_circuit_on_deny: true, + }, + }; + let mut mgr = PluginManager::new(config); + + // Register a handler that sleeps longer than the timeout + let plugin_config = make_config("slow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: plugin_config.clone() }); + let handler: Arc = Arc::new(SlowHandler { delay_ms: 5000 }); + mgr.register_raw::(plugin, plugin_config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + let start = std::time::Instant::now(); + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let elapsed = start.elapsed(); + + // Should have timed out and denied (on_error: Fail) + assert!(!result.allowed); + assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout"); + // Should have returned in ~1s, not 5s + assert!(elapsed.as_secs() < 3, "timeout didn't fire: {}s", elapsed.as_secs()); + } + + #[tokio::test] + async fn test_fire_and_forget_returns_before_task_completes() { + use std::sync::atomic::{AtomicBool, Ordering}; + + static TASK_COMPLETED: AtomicBool = AtomicBool::new(false); + TASK_COMPLETED.store(false, Ordering::SeqCst); + + struct SlowFireAndForgetHandler; + + #[async_trait] + impl AnyHookHandler for SlowFireAndForgetHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + TASK_COMPLETED.store(true, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mut mgr = PluginManager::default(); + + let config = make_config("fire-forget", 10, PluginMode::FireAndForget); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(SlowFireAndForgetHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + + // Pipeline should return immediately — before the background task finishes + assert!(result.allowed); + assert!(!TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task completed before pipeline returned"); + + // Wait for the background task to finish + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!(TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task never completed"); + } + + #[tokio::test] + async fn test_global_state_flows_between_serial_plugins() { + // Plugin A writes to global_state; Plugin B reads it. + + struct WriterHandler; + + #[async_trait] + impl AnyHookHandler for WriterHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + ctx.set_global("writer_was_here", serde_json::Value::Bool(true)); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { "test_hook" } + } + + struct ReaderHandler { + saw_writer: std::sync::Arc, + } + + #[async_trait] + impl AnyHookHandler for ReaderHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + if ctx.get_global("writer_was_here").is_some() { + self.saw_writer.store(true, std::sync::atomic::Ordering::SeqCst); + } + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { "test_hook" } + } + + let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let mut mgr = PluginManager::default(); + + // Writer runs first (priority 10) + let c1 = make_config("writer", 10, PluginMode::Sequential); + let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); + let h1: Arc = Arc::new(WriterHandler); + mgr.register_raw::(p1, c1, h1).unwrap(); + + // Reader runs second (priority 20) + let c2 = make_config("reader", 20, PluginMode::Sequential); + let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); + let h2: Arc = Arc::new(ReaderHandler { saw_writer: saw_writer.clone() }); + mgr.register_raw::(p2, c2, h2).unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + + assert!(result.allowed); + assert!( + saw_writer.load(std::sync::atomic::Ordering::SeqCst), + "reader plugin did not see writer's global_state change" + ); + } + + #[tokio::test] + async fn test_local_state_persists_across_hook_invocations() { + // Plugin writes to local_state on first hook call. + // Context table is threaded into second call — local_state preserved. + + struct LocalWriterHandler; + + #[async_trait] + impl AnyHookHandler for LocalWriterHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + // Increment a counter in local_state + let count = ctx.get_local("call_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + ctx.set_local("call_count", serde_json::Value::from(count + 1)); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { "test_hook" } + } + + let mut mgr = PluginManager::default(); + + let config = make_config("counter", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(LocalWriterHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + // First invocation — no context table, starts fresh + let payload: Box = Box::new(TestPayload { value: "first".into() }); + let result1 = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(result1.allowed); + + // Check call_count = 1 in the returned context table + let table = &result1.context_table; + let ctx = table.values().next().expect("context table should have one entry"); + assert_eq!(ctx.get_local("call_count").unwrap().as_u64().unwrap(), 1); + + // Second invocation — pass the context table from the first call + let payload2: Box = Box::new(TestPayload { value: "second".into() }); + let result2 = mgr.invoke_by_name( + "test_hook", payload2, Extensions::default(), Some(result1.context_table), + ).await; + assert!(result2.allowed); + + // call_count should now be 2 — local_state persisted across invocations + let table2 = &result2.context_table; + let ctx2 = table2.values().next().expect("context table should have one entry"); + assert_eq!(ctx2.get_local("call_count").unwrap().as_u64().unwrap(), 2); + } +} diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs new file mode 100644 index 00000000..9d4a00a6 --- /dev/null +++ b/crates/cpex-core/src/plugin.rs @@ -0,0 +1,414 @@ +// Location: ./crates/cpex-core/src/plugin.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin trait and supporting types. +// +// Defines the core Plugin trait that all plugin implementations satisfy — +// native Rust, WASM hosts, Python bridge hosts, and dlopen'd shared +// libraries. Also defines PluginConfig (YAML-declared plugin settings), +// PluginMode (5-phase execution modes), and OnError (failure behavior). +// +// The Plugin trait handles lifecycle only (initialize, shutdown, config). +// Hook-specific logic is defined by handler traits generated by the +// define_hook! macro (see hooks/macros.rs). A plugin implements Plugin +// for lifecycle + one or more handler traits for the hooks it handles. +// +// The manager wraps each plugin in a PluginRef with an authoritative +// config from the config loader — the plugin's own config() is for +// the plugin's reading only, never used by the executor for scheduling. + +use std::collections::HashSet; +use std::fmt; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::PluginError; + +// --------------------------------------------------------------------------- +// Plugin Trait +// --------------------------------------------------------------------------- + +/// Core plugin interface — lifecycle management only. +/// +/// Every plugin in the CPEX framework — regardless of language or +/// deployment model — implements this trait. It covers lifecycle +/// (initialize, shutdown) and identity (config). Hook-specific logic +/// is defined separately by handler traits generated by `define_hook!`. +/// +/// # Lifecycle +/// +/// 1. `initialize()` — called once after loading, before any hooks fire. +/// 2. Hook handlers — called on each hook invocation (defined by handler traits). +/// 3. `shutdown()` — called once during graceful teardown. +/// +/// # Hook Handlers +/// +/// A plugin implements one or more handler traits alongside Plugin: +/// +/// ```rust,ignore +/// impl Plugin for MyPlugin { +/// fn config(&self) -> &PluginConfig { &self.config } +/// async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } +/// async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } +/// } +/// +/// impl CmfHookHandler for MyPlugin { +/// fn cmf_hook(&self, payload: MessagePayload, ext: &FilteredExtensions, ctx: &PluginContext) -> PluginResult { +/// PluginResult::allow() +/// } +/// } +/// ``` +/// +/// # Trust Model +/// +/// The manager wraps each plugin in a `PluginRef` with an authoritative +/// config from the config loader. The executor reads scheduling decisions +/// (mode, priority, hooks, capabilities) from the `PluginRef` — never +/// from `plugin.config()`. The plugin's own `config()` is available for +/// the plugin's reading during hook execution. +/// +/// # Implementors +/// +/// - Native Rust plugins (implement directly) +/// - `cpex-hosts::wasm` (bridges to WASM guest via wasmtime) +/// - `cpex-hosts::python` (bridges to Python plugin classes via PyO3) +/// - `cpex-hosts::native` (bridges to dlopen'd shared libraries) +#[async_trait] +pub trait Plugin: Send + Sync { + /// Returns the plugin's configuration. + /// + /// Available for the plugin's own reading during hook execution. + /// The manager/executor never reads this — they use the authoritative + /// config from `PluginRef.trusted_config()`. + fn config(&self) -> &PluginConfig; + + /// One-time initialization after loading. + /// + /// Called before any hook invocations. Use this to establish + /// connections, load resources, or validate configuration. + async fn initialize(&self) -> Result<(), PluginError>; + + /// Graceful shutdown. + /// + /// Called once during teardown. Use this to flush buffers, close + /// connections, or release resources. + async fn shutdown(&self) -> Result<(), PluginError>; +} + +// --------------------------------------------------------------------------- +// Plugin Configuration +// --------------------------------------------------------------------------- + +/// Declared plugin configuration from the unified YAML config. +/// +/// Controls how the framework loads, schedules, and gates the plugin. +/// Corresponds to a single entry in the `plugins:` list in config YAML. +/// +/// The manager holds the authoritative copy in `PluginRef.trusted_config`. +/// The plugin receives its own copy for reading via `Plugin::config()`. +/// +/// # Examples +/// +/// ```yaml +/// plugins: +/// - name: apl-policy +/// kind: builtin +/// hooks: [tool_pre_invoke, tool_post_invoke] +/// mode: sequential +/// priority: 10 +/// on_error: fail +/// capabilities: [read_security, append_labels] +/// config: +/// policy_file: apl/demo/hr_policy.yaml +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginConfig { + /// Unique plugin name. + pub name: String, + + /// Plugin kind — determines how the framework loads it. + /// + /// - `"builtin"` — compiled into the runtime + /// - `"native://path/to/lib.so"` — dlopen'd shared library + /// - `"wasm://path/to/plugin.wasm"` — wasmtime sandbox + /// - `"python://module.path.ClassName"` — PyO3 bridge + /// - `"external"` — MCP/gRPC/Unix socket transport + pub kind: String, + + /// Human-readable description. + #[serde(default)] + pub description: Option, + + /// Plugin author or team. + #[serde(default)] + pub author: Option, + + /// Semantic version string. + #[serde(default)] + pub version: Option, + + /// Hook names this plugin handles. + #[serde(default)] + pub hooks: Vec, + + /// Execution mode — determines scheduling behavior and authority. + #[serde(default)] + pub mode: PluginMode, + + /// Execution priority — lower numbers execute first within each mode. + #[serde(default = "default_priority")] + pub priority: i32, + + /// Error handling behavior when the plugin fails. + #[serde(default)] + pub on_error: OnError, + + /// Declared capabilities for extension visibility gating. + /// + /// Controls which extensions the plugin can see and modify. + /// Extensions not covered by declared capabilities appear as + /// `None` in the filtered view. + #[serde(default)] + pub capabilities: HashSet, + + /// Tags for categorization and searchability. + #[serde(default)] + pub tags: Vec, + + /// Legacy conditions for when the plugin should execute. + /// + /// Each condition narrows the plugin's scope by server, tenant, + /// tool name, prompt name, etc. If any condition in the list + /// matches, the plugin runs. If the list is empty (default), + /// the plugin runs unconditionally. + /// + /// **Backward compatibility:** Conditions are the legacy mechanism + /// for scoping plugins. When the host uses the unified routing + /// system (`routes:` in config YAML), routing rules handle scope + /// matching and conditions on the plugin are ignored. The two + /// mechanisms should not be used together on the same plugin. + #[serde(default)] + pub conditions: Vec, + + /// Plugin-specific configuration (opaque to the framework). + #[serde(default)] + pub config: Option, +} + +fn default_priority() -> i32 { + 100 +} + +// --------------------------------------------------------------------------- +// Plugin Condition (legacy scoping) +// --------------------------------------------------------------------------- + +/// Condition for when a plugin should execute. +/// +/// Narrows plugin scope to specific servers, tenants, tools, prompts, +/// resources, or agents. All fields are optional — only specified +/// fields participate in matching. Within a field, any match suffices +/// (OR semantics). Across fields, all must match (AND semantics). +/// +/// This is the legacy scoping mechanism. The unified routing system +/// (`routes:` in config) supersedes this — when routes are used, +/// conditions are ignored. +/// +/// Mirrors Python's `PluginCondition` in `cpex/framework/models.py`. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::plugin::PluginCondition; +/// +/// // Only run for specific tools on specific servers +/// let cond = PluginCondition { +/// server_ids: Some(vec!["server-1".into(), "server-2".into()].into_iter().collect()), +/// tools: Some(vec!["get_compensation".into()].into_iter().collect()), +/// ..Default::default() +/// }; +/// assert!(cond.server_ids.as_ref().unwrap().contains("server-1")); +/// assert!(cond.tools.as_ref().unwrap().contains("get_compensation")); +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginCondition { + /// Set of server IDs — plugin runs only on these servers. + #[serde(default)] + pub server_ids: Option>, + + /// Set of tenant IDs — plugin runs only for these tenants. + #[serde(default)] + pub tenant_ids: Option>, + + /// Set of tool names — plugin runs only for these tools. + #[serde(default)] + pub tools: Option>, + + /// Set of prompt names — plugin runs only for these prompts. + #[serde(default)] + pub prompts: Option>, + + /// Set of resource identifiers — plugin runs only for these resources. + #[serde(default)] + pub resources: Option>, + + /// Set of agent identifiers — plugin runs only for these agents. + #[serde(default)] + pub agents: Option>, + + /// User patterns (glob or regex) — plugin runs only for matching users. + #[serde(default)] + pub user_patterns: Option>, + + /// Content types — plugin runs only for these content types. + #[serde(default)] + pub content_types: Option>, +} + +impl PluginCondition { + /// Whether this condition matches the given context. + /// + /// A field that is `None` is treated as "any" (no restriction). + /// A field that is `Some(set)` matches if the given value is in the set. + /// All specified fields must match (AND semantics). + pub fn matches( + &self, + server_id: Option<&str>, + tenant_id: Option<&str>, + tool: Option<&str>, + prompt: Option<&str>, + resource: Option<&str>, + agent: Option<&str>, + ) -> bool { + let check = |field: &Option>, value: Option<&str>| -> bool { + match field { + None => true, // not specified — matches anything + Some(set) => match value { + Some(v) => set.contains(v), + None => false, // field required but no value provided + }, + } + }; + + check(&self.server_ids, server_id) + && check(&self.tenant_ids, tenant_id) + && check(&self.tools, tool) + && check(&self.prompts, prompt) + && check(&self.resources, resource) + && check(&self.agents, agent) + } +} + +// --------------------------------------------------------------------------- +// Plugin Mode +// --------------------------------------------------------------------------- + +/// Execution mode — determines a plugin's scheduling behavior and authority. +/// +/// The 5-phase model defines both what a plugin *can do* (block, modify) +/// and *how it runs* (serial, parallel, background). Scheduling is derived +/// from mode; plugin authors don't control it directly. +/// +/// # Execution Order +/// +/// ```text +/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET +/// ``` +/// +/// # Mode Capabilities +/// +/// | Mode | Can Block? | Can Modify? | Execution | +/// |----------------|------------|-------------|-----------------| +/// | Sequential | Yes | Yes | Serial, chained | +/// | Transform | No | Yes | Serial, chained | +/// | Audit | No | No | Serial | +/// | Concurrent | Yes | No | Parallel | +/// | FireAndForget | No | No | Background | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginMode { + /// Policy enforcement + transformation. Serial, chained. Can block and modify. + #[default] + Sequential, + + /// Data shaping (PII redaction, normalization). Serial, chained. Can modify, cannot block. + Transform, + + /// Observation and logging. Serial, read-only. Cannot block or modify. + Audit, + + /// Independent policy gates. Parallel, fail-fast. Can block, cannot modify. + Concurrent, + + /// Telemetry and async side effects. Background tasks. Cannot block or modify. + FireAndForget, + + /// Plugin is disabled — skipped during execution. + Disabled, +} + +impl PluginMode { + /// Whether this mode allows the plugin to block the pipeline. + pub fn can_block(&self) -> bool { + matches!(self, Self::Sequential | Self::Concurrent) + } + + /// Whether this mode allows the plugin to modify the payload. + pub fn can_modify(&self) -> bool { + matches!(self, Self::Sequential | Self::Transform) + } + + /// Whether the framework waits for this plugin to complete. + pub fn is_awaited(&self) -> bool { + !matches!(self, Self::FireAndForget | Self::Disabled) + } +} + +impl fmt::Display for PluginMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Sequential => write!(f, "sequential"), + Self::Transform => write!(f, "transform"), + Self::Audit => write!(f, "audit"), + Self::Concurrent => write!(f, "concurrent"), + Self::FireAndForget => write!(f, "fire_and_forget"), + Self::Disabled => write!(f, "disabled"), + } + } +} + +// --------------------------------------------------------------------------- +// Error Handling Mode +// --------------------------------------------------------------------------- + +/// Error handling behavior when a plugin fails. +/// +/// Independent of [`PluginMode`] — any mode can use any error behavior. +/// Controls whether plugin failures halt the pipeline, are logged and +/// skipped, or cause the plugin to be auto-disabled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum OnError { + /// Pipeline halts and error propagates. Fail-safe enforcement. + #[default] + Fail, + + /// Error logged, pipeline continues. For non-critical plugins. + Ignore, + + /// Plugin auto-disabled after error. Prevents repeated failures. + Disable, +} + +impl fmt::Display for OnError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Fail => write!(f, "fail"), + Self::Ignore => write!(f, "ignore"), + Self::Disable => write!(f, "disable"), + } + } +} diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs new file mode 100644 index 00000000..cbc88a9c --- /dev/null +++ b/crates/cpex-core/src/registry.rs @@ -0,0 +1,625 @@ +// Location: ./crates/cpex-core/src/registry.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin and hook registries. +// +// PluginRef wraps a plugin implementation with the manager's +// authoritative config. The config comes from the config loader, +// NOT from the plugin — the plugin never provides its own config +// to the manager. This prevents a plugin from tampering with its +// own priority, mode, or capabilities. +// +// Trust flows one direction: +// config loader → manager → PluginRef → executor +// The plugin is just a recipient, not a source. +// +// The registry supports two registration paths: +// +// 1. **Typed** (`register::()`) — for Rust plugins implementing +// a handler trait generated by define_hook!. The handler is stored +// type-erased alongside the PluginRef. At dispatch time, the typed +// path (`invoke::()`) downcasts back; the dynamic path +// (`invoke_by_name()`) calls through the type-erased interface. +// +// 2. **Name-based** (`register_for_names::()`) — same handler +// registered under multiple hook names (the CMF pattern). +// +// Mirrors the Python framework's PluginRef and PluginInstanceRegistry +// in cpex/framework/base.py and cpex/framework/registry.py. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::context::PluginContext; +use crate::hooks::payload::{FilteredExtensions, PluginPayload}; +use crate::hooks::trait_def::HookTypeDef; +use crate::hooks::HookType; +use crate::plugin::{Plugin, PluginConfig, PluginMode}; + +// --------------------------------------------------------------------------- +// Plugin Ref — trusted wrapper +// --------------------------------------------------------------------------- + +/// Manager-owned wrapper that pairs a plugin with its authoritative config. +/// +/// The `trusted_config` comes from the config loader / manager — never +/// from the plugin itself. The executor reads all scheduling decisions +/// (mode, priority, hooks, capabilities, on_error) from this config. +/// +/// The plugin receives a copy of its config at construction time so it +/// can read its own settings during hook execution. But the manager/executor +/// never reads config back from the plugin. +/// +/// Trust flow: +/// ```text +/// config loader → manager → PluginRef.trusted_config → executor +/// ↘ plugin (receives a copy, cannot influence scheduling) +/// ``` +#[derive(Clone)] +pub struct PluginRef { + /// The plugin implementation. + plugin: Arc, + + /// Authoritative config from the config loader. + /// The executor uses this for all scheduling and capability decisions. + trusted_config: PluginConfig, + + /// Unique identifier assigned by the registry. + id: String, + + /// Runtime circuit breaker — set to true when `on_error: Disable` + /// triggers. Once set, `mode()` returns `Disabled` and the plugin + /// is skipped by `group_by_mode()` on all subsequent invocations. + /// Uses `Arc` so clones (in HookEntry) share the same flag. + disabled: Arc, +} + +impl PluginRef { + /// Create a new PluginRef with an independently-sourced config. + /// + /// The `trusted_config` must come from the config loader or manager, + /// NOT from `plugin.config()`. The plugin may hold its own copy + /// for reading during execute(), but the manager never consults it. + pub fn new(plugin: Arc, trusted_config: PluginConfig) -> Self { + let id = uuid::Uuid::new_v4().to_string(); + Self { + plugin, + trusted_config, + id, + disabled: Arc::new(AtomicBool::new(false)), + } + } + + /// The authoritative config used by the executor for all decisions. + pub fn trusted_config(&self) -> &PluginConfig { + &self.trusted_config + } + + /// The plugin implementation (for calling initialize/shutdown). + pub fn plugin(&self) -> &Arc { + &self.plugin + } + + /// Unique identifier assigned at registration. + pub fn id(&self) -> &str { + &self.id + } + + /// Convenience: plugin name from the trusted config. + pub fn name(&self) -> &str { + &self.trusted_config.name + } + + /// Effective mode — returns `Disabled` if the runtime circuit breaker + /// has tripped, otherwise returns the configured mode. + pub fn mode(&self) -> PluginMode { + if self.disabled.load(Ordering::Relaxed) { + PluginMode::Disabled + } else { + self.trusted_config.mode + } + } + + /// Runtime-disable this plugin (one-way circuit breaker). + /// + /// Called by the executor when a plugin errors with `on_error: Disable`. + /// All clones of this PluginRef (in HookEntry, etc.) share the same + /// `AtomicBool`, so the disable is instantly visible across the system. + pub fn disable(&self) { + self.disabled.store(true, Ordering::Relaxed); + } + + /// Whether this plugin has been runtime-disabled. + pub fn is_disabled(&self) -> bool { + self.disabled.load(Ordering::Relaxed) + } + + /// Convenience: plugin priority from the trusted config. + pub fn priority(&self) -> i32 { + self.trusted_config.priority + } +} + +// --------------------------------------------------------------------------- +// Type-Erased Hook Handler +// --------------------------------------------------------------------------- + +/// Type-erased interface for calling a hook handler. +/// +/// The executor uses this to dispatch hooks without knowing the +/// concrete handler trait at compile time. Each handler wraps a +/// plugin that implements a specific handler trait (e.g., +/// `CmfHookHandler`) and translates between type-erased payloads +/// and the typed handler method. +/// +/// The executor dispatches through this trait for all five phases. +/// The handler receives a borrowed payload — the framework retains +/// ownership. Plugins clone only when modifying. +/// +/// `invoke` is async so that plugins can perform I/O (HTTP calls, +/// Redis, vault lookups) without blocking the tokio runtime, and +/// so that `tokio::time::timeout` can actually observe and cancel +/// long-running handlers. +#[async_trait::async_trait] +pub trait AnyHookHandler: Send + Sync { + /// Call the handler with a borrowed payload. + /// + /// Returns an `ErasedResultFields` (see executor module) wrapped + /// as `Box`. If the handler modified the payload, the + /// modified copy is in `ErasedResultFields.modified_payload`. + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, crate::error::PluginError>; + + /// The hook type name this handler was registered for. + fn hook_type_name(&self) -> &'static str; +} + +// --------------------------------------------------------------------------- +// Hook Entry — PluginRef + handler paired together +// --------------------------------------------------------------------------- + +/// A registered hook handler paired with its PluginRef. +/// +/// The executor uses `plugin_ref` for scheduling decisions (mode, +/// priority, capabilities) and `handler` for actual dispatch. +#[derive(Clone)] +pub struct HookEntry { + /// The plugin wrapper with authoritative config. + pub plugin_ref: PluginRef, + + /// The type-erased handler for this specific hook. + pub handler: Arc, +} + +// --------------------------------------------------------------------------- +// Plugin Registry +// --------------------------------------------------------------------------- + +/// Manages registered plugin instances and hook handler mappings. +/// +/// Stores `PluginRef` wrappers by name and `HookEntry` (PluginRef + +/// handler) by hook name. The executor reads scheduling decisions +/// from `PluginRef.trusted_config` and dispatches through the +/// type-erased handler. +/// +/// Supports two registration patterns: +/// +/// - `register::()` — typed registration for a single hook name +/// (derived from `H::NAME`). +/// - `register_for_names::()` — typed registration for multiple +/// hook names (the CMF pattern where one handler covers +/// `cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). +pub struct PluginRegistry { + /// Plugins keyed by name (for lookup and lifecycle). + plugins: HashMap, + + /// Hook name → list of HookEntries, sorted by priority. + hook_index: HashMap>, +} + +impl PluginRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + plugins: HashMap::new(), + hook_index: HashMap::new(), + } + } + + /// Register a typed hook handler for its primary hook name. + /// + /// The handler is registered under `H::NAME`. The `config` must + /// come from the config loader — not from the plugin. The plugin + /// must implement the handler trait generated by `define_hook!`. + /// + /// # Type Parameters + /// + /// - `H` — the hook type (implements `HookTypeDef`). + /// + /// # Arguments + /// + /// - `plugin` — the plugin implementation (must also implement the handler trait). + /// - `config` — authoritative config from the config loader. + /// - `handler` — type-erased handler wrapping the plugin's handler trait impl. + pub fn register( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + ) -> Result<(), String> { + self.register_for_names_inner(plugin, config, handler, &[H::NAME]) + } + + /// Register a typed hook handler for multiple hook names. + /// + /// This is the CMF pattern — one handler trait impl covers multiple + /// hook names (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). + /// + /// # Arguments + /// + /// - `plugin` — the plugin implementation. + /// - `config` — authoritative config from the config loader. + /// - `handler` — type-erased handler. + /// - `names` — hook names to register under. + pub fn register_for_names( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + names: &[&str], + ) -> Result<(), String> { + self.register_for_names_inner(plugin, config, handler, names) + } + + /// Internal: register handler under one or more hook names. + fn register_for_names_inner( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + names: &[&str], + ) -> Result<(), String> { + let name = config.name.clone(); + + if self.plugins.contains_key(&name) { + return Err(format!("plugin '{}' is already registered", name)); + } + + let plugin_ref = PluginRef::new(plugin, config); + + // Add to hook index for each specified hook name + for hook_name in names { + let hook_type = HookType::new(*hook_name); + let entry = HookEntry { + plugin_ref: plugin_ref.clone(), + handler: Arc::clone(&handler), + }; + self.hook_index.entry(hook_type).or_default().push(entry); + } + + // Sort each affected hook's entry list by trusted priority + for hook_name in names { + let hook_type = HookType::new(*hook_name); + if let Some(entries) = self.hook_index.get_mut(&hook_type) { + entries.sort_by_key(|e| e.plugin_ref.priority()); + } + } + + self.plugins.insert(name, plugin_ref); + Ok(()) + } + + /// Unregister a plugin by name. + /// + /// Removes the PluginRef from the name index and all HookEntries + /// from the hook index. Returns the PluginRef if found. + pub fn unregister(&mut self, name: &str) -> Option { + let plugin_ref = self.plugins.remove(name)?; + + // Remove from hook index + for entries in self.hook_index.values_mut() { + entries.retain(|e| e.plugin_ref.name() != name); + } + + // Clean up empty hook entries + self.hook_index.retain(|_, entries| !entries.is_empty()); + + Some(plugin_ref) + } + + /// Look up a PluginRef by name. + pub fn get(&self, name: &str) -> Option<&PluginRef> { + self.plugins.get(name) + } + + /// Returns all HookEntries for a given hook name, sorted by priority. + /// + /// Returns an empty slice if no plugins are registered for the hook. + pub fn entries_for_hook(&self, hook_type: &HookType) -> &[HookEntry] { + self.hook_index + .get(hook_type) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + /// Whether any plugins are registered for the given hook name. + pub fn has_hooks_for(&self, hook_type: &HookType) -> bool { + self.hook_index + .get(hook_type) + .map(|v| !v.is_empty()) + .unwrap_or(false) + } + + /// Total number of registered plugins. + pub fn plugin_count(&self) -> usize { + self.plugins.len() + } + + /// All registered plugin names. + pub fn plugin_names(&self) -> Vec<&str> { + self.plugins.keys().map(|s| s.as_str()).collect() + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Group HookEntries by mode (used by the executor) +// --------------------------------------------------------------------------- + +/// Groups a list of HookEntries by their execution mode. +/// +/// Reads mode from `plugin_ref.trusted_config` — never from the plugin. +/// Returns a tuple of five vectors in execution order: +/// (sequential, transform, audit, concurrent, fire_and_forget). +/// Disabled plugins are excluded. +pub fn group_by_mode( + entries: &[HookEntry], +) -> ( + Vec, + Vec, + Vec, + Vec, + Vec, +) { + let mut sequential = Vec::new(); + let mut transform = Vec::new(); + let mut audit = Vec::new(); + let mut concurrent = Vec::new(); + let mut fire_and_forget = Vec::new(); + + for entry in entries { + match entry.plugin_ref.mode() { + PluginMode::Sequential => sequential.push(entry.clone()), + PluginMode::Transform => transform.push(entry.clone()), + PluginMode::Audit => audit.push(entry.clone()), + PluginMode::Concurrent => concurrent.push(entry.clone()), + PluginMode::FireAndForget => fire_and_forget.push(entry.clone()), + PluginMode::Disabled => {} // skip + } + } + + (sequential, transform, audit, concurrent, fire_and_forget) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::PluginError; + use crate::hooks::payload::PluginPayload; + use crate::hooks::PluginResult; + use async_trait::async_trait; + + // -- Test payload and hook type -- + + #[derive(Debug, Clone)] + struct TestPayload { + value: String, + } + crate::impl_plugin_payload!(TestPayload); + + // -- Test handler (type-erased wrapper) -- + + /// A simple AnyHookHandler that wraps a function for testing. + struct TestHandler; + + #[async_trait] + impl AnyHookHandler for TestHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // -- Test plugin -- + + struct TestPlugin { + cfg: PluginConfig, + } + + fn make_config(name: &str, hooks: Vec<&str>, priority: i32) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: hooks.into_iter().map(String::from).collect(), + mode: PluginMode::Sequential, + priority, + on_error: Default::default(), + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } + } + + impl TestPlugin { + fn new(cfg: PluginConfig) -> Self { + Self { cfg } + } + } + + #[async_trait] + impl Plugin for TestPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), PluginError> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), PluginError> { + Ok(()) + } + } + + // -- Tests -- + + #[test] + fn test_register_typed_and_lookup() { + let mut reg = PluginRegistry::new(); + let config = make_config("test-plugin", vec!["test_hook"], 10); + let plugin = Arc::new(TestPlugin::new(config.clone())); + let handler: Arc = Arc::new(TestHandler); + + // Use register_for_names_inner directly since we don't have a real HookTypeDef + reg.register_for_names_inner(plugin, config, handler, &["test_hook"]) + .unwrap(); + + assert_eq!(reg.plugin_count(), 1); + assert!(reg.get("test-plugin").is_some()); + assert!(reg.has_hooks_for(&HookType::new("test_hook"))); + assert!(!reg.has_hooks_for(&HookType::new("other_hook"))); + + let entries = reg.entries_for_hook(&HookType::new("test_hook")); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].plugin_ref.name(), "test-plugin"); + } + + #[test] + fn test_register_for_multiple_names() { + let mut reg = PluginRegistry::new(); + let config = make_config("cmf-plugin", vec![], 10); + let plugin = Arc::new(TestPlugin::new(config.clone())); + let handler: Arc = Arc::new(TestHandler); + + reg.register_for_names_inner( + plugin, + config, + handler, + &["cmf.tool_pre_invoke", "cmf.tool_post_invoke", "cmf.llm_input"], + ) + .unwrap(); + + assert_eq!(reg.plugin_count(), 1); + assert!(reg.has_hooks_for(&HookType::new("cmf.tool_pre_invoke"))); + assert!(reg.has_hooks_for(&HookType::new("cmf.tool_post_invoke"))); + assert!(reg.has_hooks_for(&HookType::new("cmf.llm_input"))); + assert!(!reg.has_hooks_for(&HookType::new("cmf.llm_output"))); + } + + #[test] + fn test_duplicate_registration_fails() { + let mut reg = PluginRegistry::new(); + let c1 = make_config("dup", vec![], 10); + let c2 = make_config("dup", vec![], 20); + let p1 = Arc::new(TestPlugin::new(c1.clone())); + let p2 = Arc::new(TestPlugin::new(c2.clone())); + let h1: Arc = Arc::new(TestHandler); + let h2: Arc = Arc::new(TestHandler); + + assert!(reg.register_for_names_inner(p1, c1, h1, &["hook_a"]).is_ok()); + assert!(reg.register_for_names_inner(p2, c2, h2, &["hook_a"]).is_err()); + } + + #[test] + fn test_priority_ordering_uses_trusted_config() { + let mut reg = PluginRegistry::new(); + let c_low = make_config("low", vec![], 100); + let c_high = make_config("high", vec![], 10); + let p_low = Arc::new(TestPlugin::new(c_low.clone())); + let p_high = Arc::new(TestPlugin::new(c_high.clone())); + let h1: Arc = Arc::new(TestHandler); + let h2: Arc = Arc::new(TestHandler); + + reg.register_for_names_inner(p_low, c_low, h1, &["hook_a"]).unwrap(); + reg.register_for_names_inner(p_high, c_high, h2, &["hook_a"]).unwrap(); + + let entries = reg.entries_for_hook(&HookType::new("hook_a")); + assert_eq!(entries[0].plugin_ref.name(), "high"); // priority 10 first + assert_eq!(entries[1].plugin_ref.name(), "low"); // priority 100 second + } + + #[test] + fn test_unregister() { + let mut reg = PluginRegistry::new(); + let config = make_config("removable", vec![], 10); + let plugin = Arc::new(TestPlugin::new(config.clone())); + let handler: Arc = Arc::new(TestHandler); + + reg.register_for_names_inner(plugin, config, handler, &["hook_a"]) + .unwrap(); + + assert_eq!(reg.plugin_count(), 1); + reg.unregister("removable"); + assert_eq!(reg.plugin_count(), 0); + assert!(!reg.has_hooks_for(&HookType::new("hook_a"))); + } + + #[test] + fn test_plugin_ref_id_is_unique() { + let c1 = make_config("a", vec![], 10); + let c2 = make_config("b", vec![], 10); + let p1 = Arc::new(TestPlugin::new(c1.clone())); + let p2 = Arc::new(TestPlugin::new(c2.clone())); + let ref1 = PluginRef::new(p1, c1); + let ref2 = PluginRef::new(p2, c2); + assert_ne!(ref1.id(), ref2.id()); + } + + #[test] + fn test_tampered_plugin_config_ignored() { + let trusted = make_config("sneaky", vec![], 100); + let mut tampered = trusted.clone(); + tampered.priority = 1; + let plugin = Arc::new(TestPlugin::new(tampered)); + + let plugin_ref = PluginRef::new(plugin, trusted); + assert_eq!(plugin_ref.priority(), 100); + } + + #[tokio::test] + async fn test_handler_invoke() { + let handler = TestHandler; + let payload = TestPayload { + value: "test".into(), + }; + let ext = FilteredExtensions::default(); + let mut ctx = PluginContext::new(); + + let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &mut ctx).await.unwrap(); + let fields = crate::executor::extract_erased(result).unwrap(); + assert!(fields.continue_processing); + } +} diff --git a/crates/cpex-sdk/Cargo.toml b/crates/cpex-sdk/Cargo.toml new file mode 100644 index 00000000..1077a33f --- /dev/null +++ b/crates/cpex-sdk/Cargo.toml @@ -0,0 +1,22 @@ +# Location: ./crates/cpex-sdk/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX SDK — lean crate for plugin authors. +# Re-exports the Plugin trait and payload/result types from cpex-core +# without pulling in the PluginManager, hosts, or FFI. + +[package] +name = "cpex-sdk" +description = "CPEX plugin author SDK — Plugin trait, payloads, and result types." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +cpex-core = { path = "../cpex-core" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/cpex-sdk/src/lib.rs b/crates/cpex-sdk/src/lib.rs new file mode 100644 index 00000000..6992d196 --- /dev/null +++ b/crates/cpex-sdk/src/lib.rs @@ -0,0 +1,28 @@ +// Location: ./crates/cpex-sdk/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX SDK — lean crate for plugin authors. +// +// Re-exports the Plugin trait and supporting types from cpex-core. +// Plugin authors depend on this crate instead of the full runtime, +// keeping their dependency tree minimal. This is also the crate +// that WASM plugins compile against. + +// Plugin lifecycle +pub use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +// Hook system +pub use cpex_core::hooks::{ + Extensions, FilteredExtensions, HookHandler, HookTypeDef, PluginPayload, PluginResult, +}; + +// Context +pub use cpex_core::context::PluginContext; + +// Errors +pub use cpex_core::error::{PluginError, PluginViolation}; + +// Re-export the define_hook! macro +pub use cpex_core::define_hook; From 8bdf018265b5f3233d995efbdc671cc519a112d7 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 4 May 2026 12:43:49 -0600 Subject: [PATCH 02/11] feat: CPEX Rust config (#38) * feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor * feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor * fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Co-authored-by: Teryl Taylor --- Cargo.lock | 9 + Cargo.toml | 1 + crates/cpex-core/Cargo.toml | 1 + crates/cpex-core/examples/README.md | 43 + crates/cpex-core/examples/plugin_demo.rs | 394 ++++++ crates/cpex-core/examples/plugin_demo.yaml | 59 + crates/cpex-core/src/config.rs | 1158 ++++++++++++++++- crates/cpex-core/src/error.rs | 30 + crates/cpex-core/src/executor.rs | 184 ++- crates/cpex-core/src/factory.rs | 141 +++ crates/cpex-core/src/hooks/payload.rs | 46 +- crates/cpex-core/src/lib.rs | 2 + crates/cpex-core/src/manager.rs | 1334 +++++++++++++++++++- crates/cpex-core/src/plugin.rs | 10 +- crates/cpex-core/src/registry.rs | 60 + 15 files changed, 3371 insertions(+), 101 deletions(-) create mode 100644 crates/cpex-core/examples/README.md create mode 100644 crates/cpex-core/examples/plugin_demo.rs create mode 100644 crates/cpex-core/examples/plugin_demo.yaml create mode 100644 crates/cpex-core/src/factory.rs diff --git a/Cargo.lock b/Cargo.lock index b06faa5a..8760f602 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.102" @@ -49,6 +55,7 @@ version = "0.1.0" dependencies = [ "async-trait", "futures", + "hashbrown 0.15.5", "serde", "serde_json", "serde_yaml", @@ -197,6 +204,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] diff --git a/Cargo.toml b/Cargo.toml index 03fcb104..8ee43bc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,4 @@ tracing = "0.1" uuid = { version = "1", features = ["v4"] } paste = "1" futures = "0.3" +hashbrown = "0.15" diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 4e0d4006..1a6d3351 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -25,3 +25,4 @@ thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } futures = { workspace = true } +hashbrown = { workspace = true } diff --git a/crates/cpex-core/examples/README.md b/crates/cpex-core/examples/README.md new file mode 100644 index 00000000..9be92c2d --- /dev/null +++ b/crates/cpex-core/examples/README.md @@ -0,0 +1,43 @@ +# CPEX Core Examples + +## plugin_demo + +A complete end-to-end example showing how to build plugins, load config, and invoke hooks with the CPEX runtime. + +### What it demonstrates + +- **Defining hook types and payloads** — `ToolPreInvoke` and `ToolPostInvoke` hooks with a shared `ToolInvokePayload` +- **Building plugins** — three plugins (`IdentityResolver`, `PiiGuard`, `AuditLogger`) implementing `Plugin` + `HookHandler` for different hook types +- **Multi-hook registration** — a single plugin instance (e.g., `IdentityResolver`) registered for multiple hooks (`tool_pre_invoke` and `tool_post_invoke`) via the factory pattern +- **Plugin factories** — `PluginFactory` implementations that create plugin instances and wire up typed handler adapters +- **YAML config loading** — `plugin_demo.yaml` declares plugins, policy groups, and routing rules +- **Policy groups and tag-based routing** — the `pii` policy group activates `PiiGuard` only for tools tagged with `pii` +- **Route resolution** — exact tool matches, wildcard catch-all, tag-driven plugin selection +- **PluginContext** — `global_state` used to pass PII clearance between hooks, `local_state` for per-plugin scratch data +- **BackgroundTasks** — fire-and-forget plugins (`AuditLogger`) spawn background tasks; `wait_for_background_tasks()` awaits them +- **PluginContextTable** — context table threaded from pre-invoke to post-invoke to preserve plugin state + +### Running + +From the workspace root: + +``` +cargo run --example plugin_demo +``` + +### Scenarios + +The demo runs five scenarios against three registered plugins: + +| Scenario | Tool | User | Outcome | +|----------|------|------|---------| +| 1 | get_compensation | alice (no clearance) | DENIED by pii-guard | +| 2 | get_compensation | alice (with clearance) | ALLOWED, then post-invoke fires | +| 3 | list_departments | bob | ALLOWED (no PII tag, pii-guard skipped) | +| 4 | some_other_tool | charlie | ALLOWED (wildcard route) | +| 5 | list_departments | (empty) | DENIED by identity-resolver | + +### Files + +- `plugin_demo.rs` — Rust source with plugins, factories, and main +- `plugin_demo.yaml` — YAML config with plugins, policy groups, and routes diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs new file mode 100644 index 00000000..8e5fb602 --- /dev/null +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -0,0 +1,394 @@ +// CPEX Plugin Demo +// +// Demonstrates how to: +// 1. Define hook types and payloads +// 2. Build plugins that implement HookHandler +// 3. Create plugin factories for config-driven loading +// 4. Load a YAML config with routing rules +// 5. Invoke hooks with MetaExtension for route resolution +// +// Run with: cargo run --example plugin_demo + +use std::sync::Arc; + +use async_trait::async_trait; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::executor::PipelineResult; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::{Extensions, FilteredExtensions, MetaExtension}; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +// --------------------------------------------------------------------------- +// Step 1: Define a payload and hook type +// --------------------------------------------------------------------------- + +/// The payload carried through the tool_pre_invoke hook. +#[derive(Debug, Clone)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} +cpex_core::impl_plugin_payload!(ToolInvokePayload); + +/// Hook type for tool_pre_invoke — runs before a tool executes. +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +/// Hook type for tool_post_invoke — runs after a tool executes. +struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} + +// --------------------------------------------------------------------------- +// Step 2: Build plugins +// --------------------------------------------------------------------------- + +/// Identity resolver — checks that a user is present. +struct IdentityResolver { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for IdentityResolver { + fn config(&self) -> &PluginConfig { &self.cfg } + async fn initialize(&self) -> Result<(), PluginError> { + println!(" [identity-resolver] initialized"); + Ok(()) + } + async fn shutdown(&self) -> Result<(), PluginError> { + println!(" [identity-resolver] shutdown"); + Ok(()) + } +} + +impl HookHandler for IdentityResolver { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.user.is_empty() { + println!(" [identity-resolver] DENIED: no user identity"); + return PluginResult::deny( + PluginViolation::new("no_identity", "User identity is required"), + ); + } + println!(" [identity-resolver] OK: user '{}' identified", payload.user); + PluginResult::allow() + } +} + +impl HookHandler for IdentityResolver { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + println!(" [identity-resolver] post-invoke: user '{}' completed '{}'", + payload.user, payload.tool_name); + PluginResult::allow() + } +} + +/// PII guard — blocks access to sensitive tools without clearance. +struct PiiGuard { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for PiiGuard { + fn config(&self) -> &PluginConfig { &self.cfg } + // initialize() and shutdown() use defaults — no setup needed +} + +impl HookHandler for PiiGuard { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Check if the user has PII clearance (simulated via context) + let has_clearance = ctx + .get_global("pii_clearance") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !has_clearance { + println!(" [pii-guard] DENIED: user '{}' lacks PII clearance for '{}'", + payload.user, payload.tool_name); + return PluginResult::deny( + PluginViolation::new("pii_access_denied", "PII clearance required"), + ); + } + + println!(" [pii-guard] OK: user '{}' has PII clearance", payload.user); + PluginResult::allow() + } +} + +/// Audit logger — logs all tool invocations (fire-and-forget). +struct AuditLogger { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AuditLogger { + fn config(&self) -> &PluginConfig { &self.cfg } + // initialize() and shutdown() use defaults — no setup needed +} + +impl HookHandler for AuditLogger { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + println!(" [audit-logger] LOG: user='{}' tool='{}' args='{}'", + payload.user, payload.tool_name, payload.arguments); + PluginResult::allow() + } +} + +impl HookHandler for AuditLogger { + fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + println!(" [audit-logger] LOG: post-invoke user='{}' tool='{}'", + payload.user, payload.tool_name); + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Step 3: Create plugin factories +// --------------------------------------------------------------------------- + +struct IdentityFactory; +impl PluginFactory for IdentityFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(IdentityResolver { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), + ("tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ], + }) + } +} + +struct PiiGuardFactory; +impl PluginFactory for PiiGuardFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(PiiGuard { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ], + }) + } +} + +struct AuditLoggerFactory; +impl PluginFactory for AuditLoggerFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(AuditLogger { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), + ("tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ], + }) + } +} + +// --------------------------------------------------------------------------- +// Step 4: Build extensions with MetaExtension for routing +// --------------------------------------------------------------------------- + +fn make_tool_extensions(tool_name: &str, tags: &[&str]) -> Extensions { + Extensions { + meta: Some(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(tool_name.into()), + tags: tags.iter().map(|s| s.to_string()).collect(), + ..Default::default() + }), + ..Default::default() + } +} + +// --------------------------------------------------------------------------- +// Helper to print results +// --------------------------------------------------------------------------- + +fn print_result(_label: &str, result: &PipelineResult) { + if result.continue_processing { + println!(" Result: ALLOWED"); + } else { + let violation = result.violation.as_ref().unwrap(); + println!(" Result: DENIED by '{}' — {} [{}]", + violation.plugin_name.as_deref().unwrap_or("unknown"), + violation.reason, + violation.code, + ); + } + println!(); +} + +// --------------------------------------------------------------------------- +// Step 5: Main — load config, invoke hooks, see results +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + println!("=== CPEX Plugin Demo ===\n"); + + // --- Load config from YAML file --- + let config_path = "crates/cpex-core/examples/plugin_demo.yaml"; + println!("--- Loading config from {} ---\n", config_path); + let yaml = std::fs::read_to_string(config_path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); + let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); + + let mut mgr = PluginManager::default(); + mgr.register_factory("builtin/identity", Box::new(IdentityFactory)); + mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory)); + mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); + mgr.load_config(cpex_config).unwrap(); + + println!("\n--- Initializing plugins ---\n"); + mgr.initialize().await.unwrap(); + + println!("\nPlugins loaded: {}", mgr.plugin_count()); + println!("Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n", + mgr.has_hooks_for("tool_pre_invoke"), + mgr.has_hooks_for("tool_post_invoke"), + ); + + // --- Scenario 1: PII tool without clearance --- + println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n"); + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_tool_extensions("get_compensation", &[]); + let (result, bg) = mgr.invoke::( + payload, ext, None, + ).await; + print_result("get_compensation (no clearance)", &result); + // Wait for any fire-and-forget tasks + bg.wait_for_background_tasks().await; + + // --- Scenario 2: PII tool with clearance --- + println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n"); + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_tool_extensions("get_compensation", &[]); + // Simulate clearance by pre-populating global_state + // (In production, an earlier hook would set this from a token claim) + let mut global_state = std::collections::HashMap::new(); + global_state.insert( + "pii_clearance".into(), + serde_json::Value::Bool(true), + ); + // Pass global state via context table + let mut ctx_table = cpex_core::context::PluginContextTable::new(); + // We need to seed global_state — create a dummy entry + ctx_table.insert( + "__seed__".into(), + cpex_core::context::PluginContext::with_global_state(global_state), + ); + let (result, bg) = mgr.invoke::( + payload, ext, Some(ctx_table), + ).await; + print_result("get_compensation (with clearance)", &result); + bg.wait_for_background_tasks().await; + + // Now call post-invoke — threads the context table from pre-invoke + println!(" --- post-invoke for get_compensation ---\n"); + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_tool_extensions("get_compensation", &[]); + let (post_result, bg) = mgr.invoke::( + payload, ext, Some(result.context_table), + ).await; + print_result("get_compensation post-invoke", &post_result); + bg.wait_for_background_tasks().await; + + // --- Scenario 3: Non-PII tool --- + println!("=== Scenario 3: list_departments (non-PII tool) ===\n"); + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "bob".into(), + arguments: "".into(), + }; + let ext = make_tool_extensions("list_departments", &[]); + let (result, bg) = mgr.invoke::( + payload, ext, None, + ).await; + print_result("list_departments", &result); + bg.wait_for_background_tasks().await; + + // --- Scenario 4: Unknown tool (wildcard route) --- + println!("=== Scenario 4: some_other_tool (wildcard route) ===\n"); + let payload = ToolInvokePayload { + tool_name: "some_other_tool".into(), + user: "charlie".into(), + arguments: "foo=bar".into(), + }; + let ext = make_tool_extensions("some_other_tool", &[]); + let (result, bg) = mgr.invoke::( + payload, ext, None, + ).await; + print_result("some_other_tool (wildcard)", &result); + bg.wait_for_background_tasks().await; + + // --- Scenario 5: No user identity --- + println!("=== Scenario 5: list_departments (no user identity) ===\n"); + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "".into(), + arguments: "".into(), + }; + let ext = make_tool_extensions("list_departments", &[]); + let (result, bg) = mgr.invoke::( + payload, ext, None, + ).await; + print_result("list_departments (no user)", &result); + bg.wait_for_background_tasks().await; + + // --- Shutdown --- + println!("--- Shutting down ---\n"); + mgr.shutdown().await; + + println!("=== Demo complete ==="); +} diff --git a/crates/cpex-core/examples/plugin_demo.yaml b/crates/cpex-core/examples/plugin_demo.yaml new file mode 100644 index 00000000..9e3dd610 --- /dev/null +++ b/crates/cpex-core/examples/plugin_demo.yaml @@ -0,0 +1,59 @@ +# CPEX Plugin Demo Configuration +# +# Three plugins, policy groups with tag-based activation, +# and routes that map tools to different plugin combinations. + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + # "all" is reserved — these plugins fire on every invocation + all: + plugins: [identity-resolver] + # "pii" group — activated when a route has the "pii" tag + pii: + plugins: [pii-guard] + +plugins: + - name: identity-resolver + kind: builtin/identity + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + config: + clearance_level: confidential + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + +routes: + # HR compensation tool — contains PII, gets full security stack + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + # Public department listing — standard security only + - tool: list_departments + plugins: + - audit-logger + + # Wildcard — catch-all for unmatched tools + - tool: "*" + plugins: + - audit-logger diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 02496747..375094e5 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -5,11 +5,1157 @@ // // Unified YAML configuration parsing. // -// Parses the unified config format that combines global settings, -// plugin declarations, named policy groups, and per-entity routes -// into a single YAML document. +// Parses the config format that combines global settings, plugin +// declarations, and per-entity routes into a single YAML document. // -// Mirrors the unified config proposal in -// apl-plugins/docs/unified-config-proposal.md. +// Supports two modes controlled by `plugin_settings.routing_enabled`: +// - false (default, backward compatible): plugins declare their +// own conditions for when they fire. +// - true: per-entity routing rules determine which plugins fire, +// with plugin selection via policy groups and meta.tags. +// +// The two modes are mutually exclusive. When routing is disabled, +// the routes and global sections are ignored. When routing is +// enabled, conditions on individual plugins are ignored. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::error::PluginError; +use crate::plugin::PluginConfig; + +// --------------------------------------------------------------------------- +// Top-Level Config +// --------------------------------------------------------------------------- + +/// Top-level CPEX configuration. +/// +/// Parsed from a single YAML file. Plugin scoping mode is controlled +/// by `plugin_settings.routing_enabled` — if absent or false, plugins +/// use their own `conditions:` field (backward compatible). If true, +/// the `routes:` and `global:` sections take over. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CpexConfig { + /// Global configuration — policies, defaults. + /// Only used when `plugin_settings.routing_enabled` is true. + #[serde(default)] + pub global: GlobalConfig, + + /// Directories to scan for plugin modules. + #[serde(default)] + pub plugin_dirs: Vec, + + /// Plugin declarations. + #[serde(default)] + pub plugins: Vec, + + /// Per-entity routing rules. + /// Only used when `plugin_settings.routing_enabled` is true. + #[serde(default)] + pub routes: Vec, + + /// Global plugin settings (timeout, error behavior, routing mode). + #[serde(default)] + pub plugin_settings: PluginSettings, +} + +impl CpexConfig { + /// Whether route-based plugin selection is enabled. + pub fn routing_enabled(&self) -> bool { + self.plugin_settings.routing_enabled + } +} + +// --------------------------------------------------------------------------- +// Plugin Settings +// --------------------------------------------------------------------------- + +/// Global plugin settings. +/// +/// Controls executor behavior and routing mode. All fields have +/// sensible defaults — a missing `plugin_settings:` section is valid. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSettings { + /// Enable route-based plugin selection. + /// When false (default), plugins use their own `conditions:` field. + /// When true, the `routes:` and `global:` sections determine which + /// plugins fire per entity. + #[serde(default)] + pub routing_enabled: bool, + + /// Default timeout per plugin in seconds. + #[serde(default = "default_timeout")] + pub plugin_timeout: u64, + + /// Whether to halt on first deny in concurrent mode. + #[serde(default = "default_true")] + pub short_circuit_on_deny: bool, + + /// Whether plugins can execute in parallel within a mode band. + #[serde(default)] + pub parallel_execution_within_band: bool, + + /// Whether to halt the pipeline on any plugin error. + #[serde(default)] + pub fail_on_plugin_error: bool, +} + +impl Default for PluginSettings { + fn default() -> Self { + Self { + routing_enabled: false, + plugin_timeout: 30, + short_circuit_on_deny: true, + parallel_execution_within_band: false, + fail_on_plugin_error: false, + } + } +} + +fn default_timeout() -> u64 { + 30 +} + +fn default_true() -> bool { + true +} + +// --------------------------------------------------------------------------- +// Global Config +// --------------------------------------------------------------------------- + +/// Global configuration — applies across all routes. +/// +/// Only used when routing is enabled. Contains named policy groups +/// (including the reserved `all` group) and per-entity-type defaults. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GlobalConfig { + /// Named policy groups. The reserved name `all` is applied to + /// every request unconditionally. Other groups are inherited + /// by routes via `meta.tags`. + #[serde(default)] + pub policies: HashMap, + + /// Per-entity-type default policy groups. + /// Keys are `tool`, `resource`, `prompt`, `llm`. + #[serde(default)] + pub defaults: HashMap, +} + +// --------------------------------------------------------------------------- +// Policy Group +// --------------------------------------------------------------------------- + +/// A named policy group — plugins to activate and optional metadata. +/// +/// The `all` group is reserved and always applied. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PolicyGroup { + /// Human-readable description. + #[serde(default)] + pub description: Option, + + /// Arbitrary metadata for tooling and audit. + #[serde(default)] + pub metadata: HashMap, + + /// Plugin references to activate when this group matches. + #[serde(default)] + pub plugins: Vec, +} + +// --------------------------------------------------------------------------- +// Plugin Ref (route/group plugin reference) +// --------------------------------------------------------------------------- + +/// A reference to a plugin in a route or policy group. +/// +/// ```yaml +/// plugins: +/// - rate_limiter # bare name +/// - pii_scanner: # name with config overrides +/// config: +/// sensitivity: high +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PluginRef { + /// Just the name — activate the plugin with no config overrides. + Name(String), + /// Name with config overrides — single-key map. + WithOverrides(HashMap), +} + +impl PluginRef { + /// Extract the plugin name from this reference. + pub fn name(&self) -> &str { + match self { + Self::Name(name) => name, + Self::WithOverrides(map) => map.keys().next().map(|s| s.as_str()).unwrap_or(""), + } + } + + /// Extract config overrides, if any. + pub fn overrides(&self) -> Option<&serde_json::Value> { + match self { + Self::Name(_) => None, + Self::WithOverrides(map) => map.values().next(), + } + } +} + +// --------------------------------------------------------------------------- +// Route Entry +// --------------------------------------------------------------------------- + +/// A per-entity routing rule. +/// +/// Matches one entity type (tool, resource, prompt, or LLM) and +/// determines which plugins fire. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RouteEntry { + /// Match a tool by exact name, list, or glob. + #[serde(default)] + pub tool: Option, + + /// Match a resource by exact URI, list, or glob. + #[serde(default)] + pub resource: Option, + + /// Match a prompt by exact name, list, or glob. + #[serde(default)] + pub prompt: Option, + + /// Match an LLM by exact model name, list, or glob. + #[serde(default)] + pub llm: Option, + + /// Operational metadata — tags, scope, properties. + #[serde(default)] + pub meta: Option, + + /// Conditional match expression — carried but not evaluated + /// during static resolution. Evaluated at runtime when payload + /// data is available (future: APL evaluator). + #[serde(default)] + pub when: Option, + + /// Plugin references to activate for this route. + #[serde(default)] + pub plugins: Vec, +} + +// --------------------------------------------------------------------------- +// Route Meta +// --------------------------------------------------------------------------- + +/// Operational metadata on a route entry. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RouteMeta { + /// Entity tags — drive policy group inheritance. + #[serde(default)] + pub tags: Vec, + + /// Host-defined grouping (virtual server ID, namespace, etc.). + /// Used for scope matching: route scope must match request scope. + #[serde(default)] + pub scope: Option, + + /// Arbitrary key-value metadata. + #[serde(default)] + pub properties: HashMap, +} + +// --------------------------------------------------------------------------- +// String or List (for tool matching) +// --------------------------------------------------------------------------- + +/// A tool matcher — single name, list of names, or glob pattern. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum StringOrList { + /// Single string (exact name or glob pattern). + Single(String), + /// List of exact names. + List(Vec), +} + +impl Default for StringOrList { + fn default() -> Self { + Self::Single(String::new()) + } +} + +impl StringOrList { + /// Check if this matcher matches the given name. + pub fn matches(&self, name: &str) -> bool { + match self { + Self::Single(pattern) => { + if pattern == "*" { + true + } else if pattern.contains('*') { + let prefix = pattern.trim_end_matches('*'); + name.starts_with(prefix) + } else { + name == pattern + } + } + Self::List(names) => names.iter().any(|n| n == name), + } + } +} + +// --------------------------------------------------------------------------- +// Config Loading +// --------------------------------------------------------------------------- + +/// Load and parse a CPEX config from a YAML file. +pub fn load_config(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| PluginError::Config { + message: format!("failed to read config file '{}': {}", path.display(), e), + })?; + parse_config(&content) +} + +/// Parse a CPEX config from a YAML string. +pub fn parse_config(yaml: &str) -> Result { + let config: CpexConfig = + serde_yaml::from_str(yaml).map_err(|e| PluginError::Config { + message: format!("failed to parse config YAML: {}", e), + })?; + validate_config(&config)?; + Ok(config) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validate a parsed config for structural correctness. +fn validate_config(config: &CpexConfig) -> Result<(), PluginError> { + let mut seen_names = HashSet::new(); + for plugin in &config.plugins { + if !seen_names.insert(&plugin.name) { + return Err(PluginError::Config { + message: format!("duplicate plugin name: '{}'", plugin.name), + }); + } + } + + if config.routing_enabled() { + let plugin_names: HashSet<&str> = + config.plugins.iter().map(|p| p.name.as_str()).collect(); + + for (i, route) in config.routes.iter().enumerate() { + let count = [ + route.tool.is_some(), + route.resource.is_some(), + route.prompt.is_some(), + route.llm.is_some(), + ] + .iter() + .filter(|&&m| m) + .count(); + + if count == 0 { + return Err(PluginError::Config { + message: format!( + "route {} has no entity matcher (need tool, resource, prompt, or llm)", + i + ), + }); + } + if count > 1 { + return Err(PluginError::Config { + message: format!("route {} has multiple entity matchers (need exactly one)", i), + }); + } + + for plugin_ref in &route.plugins { + if !plugin_names.contains(plugin_ref.name()) { + return Err(PluginError::Config { + message: format!("route {} references unknown plugin '{}'", i, plugin_ref.name()), + }); + } + } + } + + for (group_name, group) in &config.global.policies { + for plugin_ref in &group.plugins { + if !plugin_names.contains(plugin_ref.name()) { + return Err(PluginError::Config { + message: format!( + "policy group '{}' references unknown plugin '{}'", + group_name, + plugin_ref.name() + ), + }); + } + } + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Route Resolution +// --------------------------------------------------------------------------- + +/// Specificity scores for route matching. +const SPECIFICITY_EXACT_NAME: usize = 1000; +const SPECIFICITY_NAME_LIST: usize = 500; +const SPECIFICITY_GLOB: usize = 300; +const SPECIFICITY_WHEN_ONLY: usize = 10; +const SPECIFICITY_WILDCARD: usize = 0; + +/// Resolve which plugins should fire for a given entity. +/// +/// When routing is disabled, returns all plugin names. When enabled, +/// matches the entity against routes and collects plugins from the +/// `all` group, defaults, matching policy groups (via merged tags), +/// and the route itself. +/// +/// `request_scope` and `request_tags` come from the host's +/// `MetaExtension` on the request. +pub fn resolve_plugins_for_entity( + config: &CpexConfig, + entity_type: &str, + entity_name: &str, + request_scope: Option<&str>, + request_tags: &HashSet, +) -> Vec { + if !config.routing_enabled() { + return config + .plugins + .iter() + .map(|p| ResolvedPlugin { + name: p.name.clone(), + config_overrides: None, + when: None, + }) + .collect(); + } + + let mut resolved = Vec::new(); + + // 1. Always include plugins from the "all" policy group + if let Some(all_group) = config.global.policies.get("all") { + collect_plugin_refs(&all_group.plugins, &mut resolved, None); + } + + // 2. Include plugins from matching defaults + if let Some(default_group) = config.global.defaults.get(entity_type) { + collect_plugin_refs(&default_group.plugins, &mut resolved, None); + } + + // 3. Find matching route (with scope check) + if let Some(route) = find_matching_route(config, entity_type, entity_name, request_scope) { + // Merge tags: route's static tags + host's runtime tags + let mut merged_tags: HashSet = request_tags.clone(); + if let Some(meta) = &route.meta { + for tag in &meta.tags { + merged_tags.insert(tag.clone()); + } + } + + // Include plugins from all matching policy groups (merged tags) + for tag in &merged_tags { + if tag == "all" { + continue; // already handled above + } + if let Some(group) = config.global.policies.get(tag.as_str()) { + collect_plugin_refs(&group.plugins, &mut resolved, None); + } + } + + // Include route-level plugins, carrying the route's when clause + collect_plugin_refs(&route.plugins, &mut resolved, route.when.as_deref()); + } + + // Deduplicate by name, preserving order. Later overrides win. + let mut seen = HashSet::new(); + let mut deduped = Vec::new(); + for rp in resolved.into_iter().rev() { + if seen.insert(rp.name.clone()) { + deduped.push(rp); + } + } + deduped.reverse(); + deduped +} + +/// A resolved plugin with optional config overrides and when clause. +#[derive(Debug, Clone)] +pub struct ResolvedPlugin { + /// Plugin name. + pub name: String, + + /// Config overrides from the route. + pub config_overrides: Option, + + /// When clause from the route — carried but not evaluated here. + pub when: Option, +} + +/// Collect plugin refs into the resolved list. +fn collect_plugin_refs( + refs: &[PluginRef], + resolved: &mut Vec, + route_when: Option<&str>, +) { + for plugin_ref in refs { + resolved.push(ResolvedPlugin { + name: plugin_ref.name().to_string(), + config_overrides: plugin_ref.overrides().cloned(), + when: route_when.map(String::from), + }); + } +} + +/// Find the best matching route for an entity by specificity. +/// +/// Scope matching: if a route declares a scope, the request must +/// have the same scope. No scope on the route matches any request. +fn find_matching_route<'a>( + config: &'a CpexConfig, + entity_type: &str, + entity_name: &str, + request_scope: Option<&str>, +) -> Option<&'a RouteEntry> { + let mut best: Option<(usize, &RouteEntry)> = None; + + for route in &config.routes { + // Check scope compatibility + let route_scope = route.meta.as_ref().and_then(|m| m.scope.as_deref()); + let scope_bonus = match (route_scope, request_scope) { + (None, _) => 0, // route is global + (Some(rs), Some(rq)) if rs == rq => 100, // scopes match + (Some(_), _) => continue, // scope mismatch — skip + }; + + let base_specificity = match entity_type { + "tool" => { + if let Some(matcher) = &route.tool { + if !matcher.matches(entity_name) { + continue; + } + match matcher { + StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + } + } else { + continue; + } + } + "resource" => { + if let Some(matcher) = &route.resource { + if !matcher.matches(entity_name) { + continue; + } + match matcher { + StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + } + } else { + continue; + } + } + "prompt" => { + if let Some(matcher) = &route.prompt { + if !matcher.matches(entity_name) { + continue; + } + match matcher { + StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + } + } else { + continue; + } + } + "llm" => { + if let Some(matcher) = &route.llm { + if !matcher.matches(entity_name) { + continue; + } + match matcher { + StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + } + } else { + continue; + } + } + _ => continue, + }; + + let when_bonus = if route.when.is_some() { SPECIFICITY_WHEN_ONLY } else { 0 }; + let total = base_specificity + scope_bonus + when_bonus; + + if best.map_or(true, |(s, _)| total > s) { + best = Some((total, route)); + } + } + + best.map(|(_, route)| route) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Helper: empty tags for tests that don't need them + fn no_tags() -> HashSet { + HashSet::new() + } + + #[test] + fn test_parse_minimal_config() { + let yaml = r#" +plugins: + - name: rate_limiter + kind: builtin + hooks: [tool_pre_invoke] + mode: sequential + priority: 5 + config: + max_requests: 100 +"#; + let config = parse_config(yaml).unwrap(); + assert!(!config.routing_enabled()); + assert_eq!(config.plugins.len(), 1); + assert_eq!(config.plugins[0].name, "rate_limiter"); + } + + #[test] + fn test_no_plugin_settings_defaults_routing_disabled() { + let yaml = r#" +plugins: + - name: test + kind: builtin + hooks: [tool_pre_invoke] +"#; + let config = parse_config(yaml).unwrap(); + assert!(!config.routing_enabled()); + assert_eq!(config.plugin_settings.plugin_timeout, 30); + } + + #[test] + fn test_routing_enabled() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [identity] +plugins: + - name: identity + kind: builtin + hooks: [identity_resolve] +routes: + - tool: get_compensation + meta: + tags: [pii] +"#; + let config = parse_config(yaml).unwrap(); + assert!(config.routing_enabled()); + } + + #[test] + fn test_duplicate_plugin_names_rejected() { + let yaml = r#" +plugins: + - name: dup + kind: builtin + hooks: [tool_pre_invoke] + - name: dup + kind: builtin + hooks: [tool_post_invoke] +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("duplicate plugin name")); + } + + #[test] + fn test_route_requires_one_entity_matcher() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: [] +routes: + - meta: + tags: [pii] +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("no entity matcher")); + } + + #[test] + fn test_route_rejects_multiple_entity_matchers() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: [] +routes: + - tool: get_compensation + resource: "hr://employees/*" +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("multiple entity matchers")); + } + + #[test] + fn test_route_unknown_plugin_rejected() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: known + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + plugins: + - unknown +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("unknown plugin 'unknown'")); + } + + #[test] + fn test_policy_group_unknown_plugin_rejected() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [nonexistent] +plugins: [] +routes: [] +"#; + assert!(parse_config(yaml) + .unwrap_err() + .to_string() + .contains("unknown plugin 'nonexistent'")); + } + + #[test] + fn test_resolve_conditions_mode_returns_all() { + let yaml = r#" +plugins: + - name: a + kind: builtin + hooks: [tool_pre_invoke] + - name: b + kind: builtin + hooks: [tool_post_invoke] +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity(&config, "tool", "anything", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["a", "b"]); + } + + #[test] + fn test_resolve_routes_inherits_policy_groups() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: + - identity + pii: + plugins: + - apl_policy +plugins: + - name: identity + kind: builtin + hooks: [identity_resolve] + - name: apl_policy + kind: builtin + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_compensation + meta: + tags: [pii] +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"identity")); + assert!(names.contains(&"apl_policy")); + } + + #[test] + fn test_resolve_no_matching_route_gets_all_only() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: + - identity +plugins: + - name: identity + kind: builtin + hooks: [identity_resolve] +routes: + - tool: get_compensation +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["identity"]); + } + + #[test] + fn test_exact_match_beats_glob() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: specific + kind: builtin + hooks: [tool_pre_invoke] + - name: general + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: "hr-*" + plugins: + - general + - tool: hr-compensation + plugins: + - specific +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"specific")); + assert!(!names.contains(&"general")); + } + + #[test] + fn test_plugin_ref_bare_name() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: rate_limiter + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + plugins: + - rate_limiter +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + assert_eq!(resolved[0].name, "rate_limiter"); + assert!(resolved[0].config_overrides.is_none()); + } + + #[test] + fn test_plugin_ref_with_overrides() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: rate_limiter + kind: builtin + hooks: [tool_pre_invoke] + config: + max_requests: 100 +routes: + - tool: get_compensation + plugins: + - rate_limiter: + config: + max_requests: 10 +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + assert_eq!(resolved[0].name, "rate_limiter"); + assert!(resolved[0].config_overrides.is_some()); + let overrides = resolved[0].config_overrides.as_ref().unwrap(); + assert_eq!(overrides["config"]["max_requests"], 10); + } + + #[test] + fn test_plugin_ref_mixed_bare_and_overrides() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: rate_limiter + kind: builtin + hooks: [tool_pre_invoke] + - name: pii_scanner + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + plugins: + - rate_limiter + - pii_scanner: + config: + sensitivity: high +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0].name, "rate_limiter"); + assert!(resolved[0].config_overrides.is_none()); + assert_eq!(resolved[1].name, "pii_scanner"); + assert!(resolved[1].config_overrides.is_some()); + } + + #[test] + fn test_deduplication_preserves_order() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [a, b] + pii: + plugins: [b, c] +plugins: + - name: a + kind: builtin + hooks: [tool_pre_invoke] + - name: b + kind: builtin + hooks: [tool_pre_invoke] + - name: c + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + meta: + tags: [pii] +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["a", "b", "c"]); + } + + #[test] + fn test_glob_matches() { + let matcher = StringOrList::Single("hr-*".to_string()); + assert!(matcher.matches("hr-compensation")); + assert!(matcher.matches("hr-benefits")); + assert!(!matcher.matches("finance-report")); + } + + #[test] + fn test_wildcard_matches_everything() { + let matcher = StringOrList::Single("*".to_string()); + assert!(matcher.matches("anything")); + } + + #[test] + fn test_list_matches_any_member() { + let matcher = StringOrList::List(vec![ + "get_compensation".to_string(), + "get_benefits".to_string(), + ]); + assert!(matcher.matches("get_compensation")); + assert!(matcher.matches("get_benefits")); + assert!(!matcher.matches("send_email")); + } + + #[test] + fn test_validation_skipped_when_routing_disabled() { + let yaml = r#" +plugins: + - name: test + kind: builtin + hooks: [tool_pre_invoke] +routes: + - meta: + tags: [pii] +"#; + let config = parse_config(yaml); + assert!(config.is_ok()); + } + + // -- Scope matching tests -- + + #[test] + fn test_scope_match_selects_scoped_route() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: scoped_plugin + kind: builtin + hooks: [tool_pre_invoke] + - name: global_plugin + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + meta: + scope: hr-services + plugins: + - scoped_plugin + - tool: get_compensation + plugins: + - global_plugin +"#; + let config = parse_config(yaml).unwrap(); + + // With matching scope — scoped route wins (more specific) + let resolved = resolve_plugins_for_entity( + &config, "tool", "get_compensation", Some("hr-services"), &no_tags(), + ); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"scoped_plugin")); + assert!(!names.contains(&"global_plugin")); + + // Without scope — global route matches + let resolved = resolve_plugins_for_entity( + &config, "tool", "get_compensation", None, &no_tags(), + ); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"global_plugin")); + assert!(!names.contains(&"scoped_plugin")); + + // With different scope — global route matches (scoped doesn't) + let resolved = resolve_plugins_for_entity( + &config, "tool", "get_compensation", Some("billing"), &no_tags(), + ); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"global_plugin")); + assert!(!names.contains(&"scoped_plugin")); + } + + // -- Tag merging tests -- + + #[test] + fn test_host_tags_merged_with_route_tags() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + pii: + plugins: [pii_plugin] + runtime_tag: + plugins: [runtime_plugin] +plugins: + - name: pii_plugin + kind: builtin + hooks: [tool_pre_invoke] + - name: runtime_plugin + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + meta: + tags: [pii] +"#; + let config = parse_config(yaml).unwrap(); + + // Host provides a runtime tag that matches a policy group + let mut host_tags = HashSet::new(); + host_tags.insert("runtime_tag".to_string()); + + let resolved = resolve_plugins_for_entity( + &config, "tool", "get_compensation", None, &host_tags, + ); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + + // Both route's static tag (pii) and host's runtime tag activate their groups + assert!(names.contains(&"pii_plugin")); + assert!(names.contains(&"runtime_plugin")); + } + + // -- When clause carried tests -- + + #[test] + fn test_when_clause_carried_on_resolved_plugins() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: conditional_plugin + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + when: "args.include_ssn == true" + plugins: + - conditional_plugin +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity( + &config, "tool", "get_compensation", None, &no_tags(), + ); + assert_eq!(resolved[0].name, "conditional_plugin"); + assert_eq!(resolved[0].when.as_deref(), Some("args.include_ssn == true")); + } + + #[test] + fn test_when_clause_not_on_policy_group_plugins() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [global_plugin] +plugins: + - name: global_plugin + kind: builtin + hooks: [tool_pre_invoke] + - name: route_plugin + kind: builtin + hooks: [tool_pre_invoke] +routes: + - tool: get_compensation + when: "args.sensitive == true" + plugins: + - route_plugin +"#; + let config = parse_config(yaml).unwrap(); + let resolved = resolve_plugins_for_entity( + &config, "tool", "get_compensation", None, &no_tags(), + ); + + // global_plugin has no when clause (from all group) + let global = resolved.iter().find(|r| r.name == "global_plugin").unwrap(); + assert!(global.when.is_none()); -// TODO: Implement CpexConfig, GlobalConfig, RouteEntry serde models + // route_plugin carries the route's when clause + let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap(); + assert_eq!(route.when.as_deref(), Some("args.sensitive == true")); + } +} diff --git a/crates/cpex-core/src/error.rs b/crates/cpex-core/src/error.rs index 4b684d54..fd253429 100644 --- a/crates/cpex-core/src/error.rs +++ b/crates/cpex-core/src/error.rs @@ -24,6 +24,12 @@ use thiserror::Error; /// Covers plugin execution failures, policy violations, timeouts, /// and configuration issues. Each variant carries enough context /// for the caller to log, report, or recover. +/// +/// Mirrors the Python framework's `PluginErrorModel` with: +/// - `code` — business-logic error code (e.g., `"rate_limit_exceeded"`) +/// - `details` — structured diagnostic data for logging +/// - `proto_error_code` — protocol-level error code for the host to +/// map back to the wire format (MCP JSON-RPC, HTTP status, etc.) #[derive(Debug, Error)] pub enum PluginError { /// A plugin raised an execution error. @@ -31,8 +37,17 @@ pub enum PluginError { Execution { plugin_name: String, message: String, + /// Business-logic error code (e.g., `"invalid_token"`). #[source] source: Option>, + /// Business-logic error code set by the plugin. + code: Option, + /// Structured diagnostic data for logging or debugging. + details: HashMap, + /// Protocol-level error code for the host to map to the wire + /// format. MCP: JSON-RPC codes (e.g., -32603). HTTP: status + /// codes. The host interprets this; CPEX just carries it. + proto_error_code: Option, }, /// A plugin exceeded its execution timeout. @@ -40,6 +55,8 @@ pub enum PluginError { Timeout { plugin_name: String, timeout_ms: u64, + /// Protocol-level error code for the host. + proto_error_code: Option, }, /// A plugin returned a policy violation (deny). @@ -93,6 +110,12 @@ pub struct PluginViolation { /// Name of the plugin that produced the violation. /// Set by the framework after the plugin returns, not by the plugin itself. pub plugin_name: Option, + + /// Protocol-level error code for the host to map to the wire format. + /// MCP: JSON-RPC codes (e.g., -32603). HTTP: status codes (e.g., 403). + /// Set by the plugin; the host interprets it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proto_error_code: Option, } impl PluginViolation { @@ -104,6 +127,7 @@ impl PluginViolation { description: None, details: HashMap::new(), plugin_name: None, + proto_error_code: None, } } @@ -118,6 +142,12 @@ impl PluginViolation { self.details = details; self } + + /// Attach a protocol-level error code. + pub fn with_proto_error_code(mut self, code: i64) -> Self { + self.proto_error_code = Some(code); + self + } } impl std::fmt::Display for PluginViolation { diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index 4b1188ef..9a6cdcd2 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -26,6 +26,7 @@ use std::any::Any; use std::collections::HashMap; +use std::fmt; use std::sync::Arc; use std::time::Duration; @@ -67,24 +68,35 @@ impl Default for ExecutorConfig { /// Aggregate result from a full hook invocation across all phases. /// /// Wraps the final payload, extensions, any violation, and the -/// context table. The caller should pass `context_table` into the -/// next hook invocation to preserve per-plugin local state across -/// hooks in the same request lifecycle. +/// context table. Immutable by design — policy decisions cannot be +/// tampered with after the executor returns them. +/// +/// The caller should pass `context_table` into the next hook +/// invocation to preserve per-plugin local state across hooks in +/// the same request lifecycle. +/// +/// Background tasks are returned separately as [`BackgroundTasks`] +/// to keep the policy result immutable. #[derive(Debug)] pub struct PipelineResult { - /// Whether the pipeline completed without a deny. - pub allowed: bool, + /// Whether the pipeline should continue processing. + /// `false` means a plugin denied — the pipeline was halted. + pub continue_processing: bool, /// The final payload after all modifications (type-erased). /// `None` if the pipeline was denied before any modifications. - pub payload: Option>, + pub modified_payload: Option>, /// The final extensions after all modifications. - pub extensions: Extensions, + /// `None` if no plugin modified extensions. + pub modified_extensions: Option, /// The violation that caused a deny, if any. pub violation: Option, + /// Optional metadata aggregated from plugins (telemetry, diagnostics). + pub metadata: Option, + /// Plugin contexts indexed by plugin ID. Thread this into the /// next hook invocation to preserve per-plugin `local_state`. pub context_table: PluginContextTable, @@ -98,10 +110,11 @@ impl PipelineResult { context_table: PluginContextTable, ) -> Self { Self { - allowed: true, - payload: Some(payload), - extensions, + continue_processing: true, + modified_payload: Some(payload), + modified_extensions: Some(extensions), violation: None, + metadata: None, context_table, } } @@ -113,13 +126,91 @@ impl PipelineResult { context_table: PluginContextTable, ) -> Self { Self { - allowed: false, - payload: None, - extensions, + continue_processing: false, + modified_payload: None, + modified_extensions: Some(extensions), violation: Some(violation), + metadata: None, context_table, } } + + /// Whether this result represents a denial. + pub fn is_denied(&self) -> bool { + !self.continue_processing + } +} + +// --------------------------------------------------------------------------- +// Background Tasks +// --------------------------------------------------------------------------- + +/// Handles to fire-and-forget background tasks spawned by the executor. +/// +/// Returned separately from [`PipelineResult`] so that the policy +/// result stays immutable. If not awaited, tasks complete on their +/// own in the background. Call `wait_for_background_tasks()` when you +/// need to ensure tasks have finished (tests, graceful shutdown, +/// audit flush). +pub struct BackgroundTasks { + tasks: Vec<(String, tokio::task::JoinHandle<()>)>, +} + +impl BackgroundTasks { + /// Create an empty set of background tasks. + pub fn empty() -> Self { + Self { tasks: Vec::new() } + } + + /// Create from a list of (plugin_name, handle) pairs. + fn from_handles(tasks: Vec<(String, tokio::task::JoinHandle<()>)>) -> Self { + Self { tasks } + } + + /// Whether there are any background tasks. + pub fn is_empty(&self) -> bool { + self.tasks.is_empty() + } + + /// Number of background tasks. + pub fn len(&self) -> usize { + self.tasks.len() + } + + /// Wait for all fire-and-forget background tasks to complete. + /// + /// Returns a list of errors from any tasks that panicked. + /// An empty list means all tasks completed successfully. + /// + /// Consumes `self` — each task handle can only be awaited once. + /// + /// If not called, background tasks still complete on their own. + /// Use this for tests, graceful shutdown, or when you need to + /// ensure audit/logging tasks have flushed before proceeding. + pub async fn wait_for_background_tasks(self) -> Vec { + let mut errors = Vec::new(); + for (plugin_name, handle) in self.tasks { + if let Err(e) = handle.await { + errors.push(crate::error::PluginError::Execution { + plugin_name, + message: format!("background task panicked: {}", e), + source: None, + code: None, + details: std::collections::HashMap::new(), + proto_error_code: None, + }); + } + } + errors + } +} + +impl fmt::Debug for BackgroundTasks { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BackgroundTasks") + .field("count", &self.tasks.len()) + .finish() + } } // --------------------------------------------------------------------------- @@ -158,19 +249,26 @@ impl Executor { /// /// # Returns /// - /// A `PipelineResult` with the final payload, extensions, violation, - /// and the updated context table for threading into the next hook. + /// A tuple of: + /// - `PipelineResult` — immutable policy result with payload, + /// extensions, violation, and context table. + /// - `BackgroundTasks` — handles to fire-and-forget tasks. Call + /// `wait_for_background_tasks()` to await them, or drop to let + /// them complete in the background. pub async fn execute( &self, entries: &[HookEntry], payload: Box, extensions: Extensions, context_table: Option, - ) -> PipelineResult { + ) -> (PipelineResult, BackgroundTasks) { let mut ctx_table = context_table.unwrap_or_default(); if entries.is_empty() { - return PipelineResult::allowed_with(payload, extensions, ctx_table); + return ( + PipelineResult::allowed_with(payload, extensions, ctx_table), + BackgroundTasks::empty(), + ); } // Group entries by mode (from trusted_config) @@ -193,7 +291,10 @@ impl Executor { ) .await { - return PipelineResult::denied(v, current_extensions, ctx_table); + return ( + PipelineResult::denied(v, current_extensions, ctx_table), + BackgroundTasks::empty(), + ); } // Phase 2: TRANSFORM — serial, chained, can modify, cannot block @@ -218,17 +319,23 @@ impl Executor { .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, &ctx_table) .await { - return PipelineResult::denied(violation, current_extensions, ctx_table); + return ( + PipelineResult::denied(violation, current_extensions, ctx_table), + BackgroundTasks::empty(), + ); } // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results - self.spawn_fire_and_forget( + let bg_handles = self.spawn_fire_and_forget( &fire_and_forget, &*current_payload, &ctx_table, ); - PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) + ( + PipelineResult::allowed_with(current_payload, current_extensions, ctx_table), + BackgroundTasks::from_handles(bg_handles), + ) } // ----------------------------------------------------------------------- @@ -547,14 +654,18 @@ impl Executor { /// Each handler runs in its own `tokio::spawn` — the pipeline does /// not wait for them. Errors and timeouts are logged but have no /// effect on the pipeline result. + /// + /// Returns the plugin name and join handle for each spawned task + /// so they can be stored on `PipelineResult` for optional awaiting + /// via `wait_for_background_tasks()`. fn spawn_fire_and_forget( &self, entries: &[HookEntry], payload: &dyn PluginPayload, ctx_table: &PluginContextTable, - ) { + ) -> Vec<(String, tokio::task::JoinHandle<()>)> { if entries.is_empty() { - return; + return Vec::new(); } let timeout_dur = Duration::from_secs(self.config.timeout_seconds); @@ -564,14 +675,17 @@ impl Executor { .map(|c| c.global_state.clone()) .unwrap_or_default(); + let mut handles = Vec::with_capacity(entries.len()); + for entry in entries { let plugin_name = entry.plugin_ref.name().to_string(); let handler = Arc::clone(&entry.handler); let owned_payload = payload.clone_boxed(); let mut ctx = PluginContext::with_global_state(global_state.clone()); let dur = timeout_dur; + let name_for_log = plugin_name.clone(); - tokio::spawn(async move { + let handle = tokio::spawn(async move { let filtered = FilteredExtensions::default(); let result = timeout( dur, @@ -582,14 +696,18 @@ impl Executor { match result { Ok(Ok(_)) => {} // discard Ok(Err(e)) => { - warn!("FIRE_AND_FORGET plugin '{}' error (ignored): {}", plugin_name, e); + warn!("FIRE_AND_FORGET plugin '{}' error (ignored): {}", name_for_log, e); } Err(_) => { - warn!("FIRE_AND_FORGET plugin '{}' timed out (ignored)", plugin_name); + warn!("FIRE_AND_FORGET plugin '{}' timed out (ignored)", name_for_log); } } }); + + handles.push((plugin_name, handle)); } + + handles } } @@ -719,8 +837,8 @@ mod tests { Extensions::default(), PluginContextTable::new(), ); - assert!(result.allowed); - assert!(result.payload.is_some()); + assert!(result.continue_processing); + assert!(result.modified_payload.is_some()); assert!(result.violation.is_none()); } @@ -732,8 +850,8 @@ mod tests { Extensions::default(), PluginContextTable::new(), ); - assert!(!result.allowed); - assert!(result.payload.is_none()); + assert!(!result.continue_processing); + assert!(result.modified_payload.is_none()); assert!(result.violation.is_some()); } @@ -743,10 +861,10 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let result = executor + let (result, _) = executor .execute(&[], payload, Extensions::default(), None) .await; - assert!(result.allowed); - assert!(result.payload.is_some()); + assert!(result.continue_processing); + assert!(result.modified_payload.is_some()); } } diff --git a/crates/cpex-core/src/factory.rs b/crates/cpex-core/src/factory.rs new file mode 100644 index 00000000..e77f80ca --- /dev/null +++ b/crates/cpex-core/src/factory.rs @@ -0,0 +1,141 @@ +// Location: ./crates/cpex-core/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin factory registry. +// +// Provides a factory pattern for creating plugin instances from +// config. The host registers factories by `kind` name before +// loading config. When the manager processes a config file, it +// looks up the factory for each plugin's `kind` and calls create(). +// +// This decouples plugin instantiation from the manager — the +// manager doesn't know how to create a "builtin" vs "wasm" vs +// "python" plugin. The factory does. +// +// Mirrors the Python framework's PluginLoader in +// cpex/framework/loader/plugin.py. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::error::PluginError; +use crate::plugin::{Plugin, PluginConfig}; +use crate::registry::AnyHookHandler; + +// --------------------------------------------------------------------------- +// Plugin Factory Trait +// --------------------------------------------------------------------------- + +/// Factory for creating plugin instances from config. +/// +/// The host registers factories by `kind` name before loading +/// config. When the manager processes a config file, it looks up +/// the factory for each plugin's `kind` and calls `create()`. +/// +/// The factory returns both the plugin and its handler because it +/// knows the concrete types — which handler traits the plugin +/// implements and which hooks it handles. +/// +/// # Examples +/// +/// ```rust,ignore +/// struct RateLimiterFactory; +/// +/// impl PluginFactory for RateLimiterFactory { +/// fn create(&self, config: &PluginConfig) +/// -> Result +/// { +/// let plugin = Arc::new(RateLimiter::from_config(config)?); +/// let handler = Arc::new(TypedHandlerAdapter::::new( +/// Arc::clone(&plugin), +/// )); +/// Ok(PluginInstance { plugin, handler }) +/// } +/// } +/// +/// let mut factories = PluginFactoryRegistry::new(); +/// factories.register("security/rate_limit", Box::new(RateLimiterFactory)); +/// ``` +pub trait PluginFactory: Send + Sync { + /// Create a plugin instance and its handler from config. + /// + /// The `config` is the plugin's entry from the YAML file. + fn create(&self, config: &PluginConfig) -> Result; +} + +/// A created plugin instance — the plugin and its type-erased handlers. +/// +/// Each handler is paired with the hook name it handles. A plugin +/// that implements multiple hook types (e.g., `ToolPreInvoke` and +/// `ToolPostInvoke`) returns one entry per hook. +pub struct PluginInstance { + /// The plugin implementation. + pub plugin: Arc, + + /// Type-erased handlers paired with their hook names. + /// Each entry maps a hook name to the adapter for that hook type. + pub handlers: Vec<(&'static str, Arc)>, +} + +// --------------------------------------------------------------------------- +// Plugin Factory Registry +// --------------------------------------------------------------------------- + +/// Registry of plugin factories keyed by `kind` name. +/// +/// The host populates this before calling `PluginManager::from_config()`. +/// Each factory knows how to create plugins of a specific kind. +/// +/// # Examples +/// +/// ```rust,ignore +/// let mut factories = PluginFactoryRegistry::new(); +/// factories.register("builtin/rate_limit", Box::new(RateLimiterFactory)); +/// factories.register("builtin/identity", Box::new(IdentityFactory)); +/// +/// let manager = PluginManager::from_config(path, &factories)?; +/// ``` +pub struct PluginFactoryRegistry { + factories: HashMap>, +} + +impl PluginFactoryRegistry { + /// Create an empty factory registry. + pub fn new() -> Self { + Self { + factories: HashMap::new(), + } + } + + /// Register a factory for a given `kind` name. + pub fn register( + &mut self, + kind: impl Into, + factory: Box, + ) { + self.factories.insert(kind.into(), factory); + } + + /// Look up a factory by `kind` name. + pub fn get(&self, kind: &str) -> Option<&dyn PluginFactory> { + self.factories.get(kind).map(|f| f.as_ref()) + } + + /// Whether a factory exists for the given `kind`. + pub fn has(&self, kind: &str) -> bool { + self.factories.contains_key(kind) + } + + /// All registered kind names. + pub fn kinds(&self) -> Vec<&str> { + self.factories.keys().map(|s| s.as_str()).collect() + } +} + +impl Default for PluginFactoryRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/cpex-core/src/hooks/payload.rs b/crates/cpex-core/src/hooks/payload.rs index c25f0247..f46d89c6 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -39,11 +39,16 @@ use serde::{Deserialize, Serialize}; /// This is a Phase 1 stub with minimal fields. Phase 3 adds the /// full CMF extension types (SecurityExtension with MonotonicSet, /// DelegationExtension with scope-narrowing chain, HttpExtension -/// with Guarded, MetaExtension, etc.). +/// with Guarded, etc.). /// /// Mirrors Python's `cpex.framework.extensions.Extensions`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Extensions { + /// Host-provided operational metadata — entity identification, + /// tags, scope, and arbitrary properties. Immutable. + #[serde(default)] + pub meta: Option, + /// Security labels (monotonic — add-only in the full implementation). #[serde(default)] pub labels: std::collections::HashSet, @@ -53,6 +58,42 @@ pub struct Extensions { pub custom: HashMap, } +/// Host-provided operational metadata about the entity being processed. +/// +/// Carries entity identification (type + name) for route resolution, +/// operational tags for policy group inheritance, scope for host-defined +/// grouping, and arbitrary properties for policy conditions. +/// +/// Immutable — set by the host before invoking the hook. Plugins +/// can read but not modify. +/// +/// Mirrors Python's `cpex.framework.extensions.meta.MetaExtension`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MetaExtension { + /// Entity type: "tool", "resource", "prompt", "llm". + /// Used by the manager for route resolution. + #[serde(default)] + pub entity_type: Option, + + /// Entity name: "get_compensation", "hr://employees/*", etc. + /// Used by the manager for route resolution. + #[serde(default)] + pub entity_name: Option, + + /// Operational tags — drive policy group inheritance. + /// Merged with static tags from the matching route's `meta.tags`. + #[serde(default)] + pub tags: std::collections::HashSet, + + /// Host-defined grouping (virtual server ID, namespace, etc.). + #[serde(default)] + pub scope: Option, + + /// Arbitrary key-value metadata. + #[serde(default)] + pub properties: HashMap, +} + /// Capability-filtered view of Extensions for a specific plugin. /// /// Built by the framework before dispatching to each plugin. Fields @@ -63,6 +104,9 @@ pub struct Extensions { /// the Python `filter_extensions()` implementation. #[derive(Debug, Clone, Default)] pub struct FilteredExtensions { + /// Meta extension (always visible — immutable, no capability needed). + pub meta: Option, + /// Security labels (visible with `read_labels` capability). pub labels: Option>, diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index 2743b238..fbede921 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -17,6 +17,7 @@ // - [`manager`] — PluginManager lifecycle and hook dispatch // - [`registry`] — PluginInstanceRegistry and HookRegistry // - [`config`] — Unified YAML configuration parsing +// - [`factory`] — Plugin factory registry for config-driven instantiation // - [`context`] — PluginContext (local_state + global_state) // - [`error`] — Error types, violations, and result types @@ -24,6 +25,7 @@ pub mod config; pub mod context; pub mod error; pub mod executor; +pub mod factory; pub mod hooks; pub mod manager; pub mod plugin; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index a2a6e8a3..0810bbba 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -24,13 +24,18 @@ // Mirrors the Python framework's PluginManager in // cpex/framework/manager.py. -use std::sync::Arc; +use std::hash::{Hash, Hasher}; +use std::path::Path; +use std::sync::{Arc, RwLock}; -use tracing::{error, info}; +use hashbrown::HashMap; +use tracing::{error, info, warn}; +use crate::config::{self, CpexConfig}; use crate::context::PluginContextTable; use crate::error::PluginError; -use crate::executor::{Executor, ExecutorConfig, PipelineResult}; +use crate::executor::{BackgroundTasks, Executor, ExecutorConfig, PipelineResult}; +use crate::factory::PluginFactoryRegistry; use crate::hooks::adapter::TypedHandlerAdapter; use crate::hooks::payload::{Extensions, PluginPayload}; use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; @@ -90,6 +95,44 @@ impl Default for ManagerConfig { /// The manager wraps each plugin in a `PluginRef` with an authoritative /// config from the config loader. The executor reads all scheduling /// decisions from `PluginRef.trusted_config` — never from the plugin. +/// Cache key for resolved routing entries. +/// +/// Includes entity type, name, hook name, and scope so that +/// the same tool on different scopes or at different hook points +/// caches separately. +/// +/// Custom Hash/Eq implementations hash on `&str` slices so that +/// `raw_entry` lookups with borrowed strings produce the same hash +/// as the owned key — enabling zero-allocation cache hits. +#[derive(Debug, Clone)] +struct RouteCacheKey { + entity_type: String, + entity_name: String, + hook_name: String, + scope: Option, +} + +impl Hash for RouteCacheKey { + fn hash(&self, state: &mut H) { + self.entity_type.as_str().hash(state); + self.entity_name.as_str().hash(state); + self.hook_name.as_str().hash(state); + self.scope.as_deref().hash(state); + } +} + +impl PartialEq for RouteCacheKey { + fn eq(&self, other: &Self) -> bool { + self.entity_type == other.entity_type + && self.entity_name == other.entity_name + && self.hook_name == other.hook_name + && self.scope == other.scope + } +} + +impl Eq for RouteCacheKey {} + + pub struct PluginManager { /// Plugin registry — stores PluginRefs and hook-to-handler mappings. registry: PluginRegistry, @@ -97,6 +140,23 @@ pub struct PluginManager { /// Executor — stateless 5-phase pipeline engine. executor: Executor, + /// Parsed CPEX config (when loaded from file). Used for route resolution. + cpex_config: Option, + + /// Factory registry — owned by the manager. Used for initial + /// instantiation and for creating override instances when routes + /// override a plugin's base config. + factories: PluginFactoryRegistry, + + /// Cache of resolved hook entries per (entity, hook, scope). + /// Populated on first access, invalidated on config reload. + /// Uses Arc so cache reads are refcount bumps (~1ns), not data copies. + route_cache: RwLock>>>, + + /// Hasher builder for zero-allocation cache lookups via raw_entry. + cache_hasher: hashbrown::DefaultHashBuilder, + + /// Whether initialize() has been called. initialized: bool, } @@ -104,13 +164,160 @@ pub struct PluginManager { impl PluginManager { /// Create a new PluginManager with the given configuration. pub fn new(config: ManagerConfig) -> Self { + let cache_hasher = hashbrown::DefaultHashBuilder::default(); Self { registry: PluginRegistry::new(), executor: Executor::new(config.executor), + cpex_config: None, + factories: PluginFactoryRegistry::new(), + route_cache: RwLock::new(HashMap::with_hasher(cache_hasher.clone())), + cache_hasher, initialized: false, } } + // ----------------------------------------------------------------------- + // Factory Registration + // ----------------------------------------------------------------------- + + /// Register a plugin factory for a given `kind` name. + /// + /// The host calls this to tell the manager how to create plugins + /// of a specific kind. Must be called before `load_config()`. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut manager = PluginManager::default(); + /// manager.register_factory("builtin", Box::new(BuiltinFactory)); + /// manager.register_factory("security/rate_limit", Box::new(RateLimiterFactory)); + /// manager.load_config(Path::new("plugins.yaml"))?; + /// ``` + pub fn register_factory( + &mut self, + kind: impl Into, + factory: Box, + ) { + self.factories.register(kind, factory); + } + + // ----------------------------------------------------------------------- + // Config Loading + // ----------------------------------------------------------------------- + + /// Load plugins from a YAML config file. + /// + /// Parses the config, looks up each plugin's `kind` in the + /// factory registry, instantiates the plugins, and registers + /// them. Factories must be registered via `register_factory()` + /// before calling this method. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut manager = PluginManager::default(); + /// manager.register_factory("builtin", Box::new(BuiltinFactory)); + /// manager.load_config_file(Path::new("plugins/config.yaml"))?; + /// manager.initialize().await?; + /// ``` + pub fn load_config_file(&mut self, path: &Path) -> Result<(), PluginError> { + let cpex_config = config::load_config(path)?; + self.load_config(cpex_config) + } + + /// Load plugins from a parsed config. + /// + /// Looks up each plugin's `kind` in the factory registry, + /// instantiates the plugins, and registers them with their + /// hook names from the config. + pub fn load_config(&mut self, cpex_config: CpexConfig) -> Result<(), PluginError> { + // Update executor settings from config + self.executor = Executor::new(ExecutorConfig { + timeout_seconds: cpex_config.plugin_settings.plugin_timeout, + short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, + }); + + // Instantiate and register each plugin from config + for plugin_config in &cpex_config.plugins { + let factory = self.factories.get(&plugin_config.kind).ok_or_else(|| { + PluginError::Config { + message: format!( + "no factory registered for plugin kind '{}' (plugin '{}')", + plugin_config.kind, plugin_config.name + ), + } + })?; + + let instance = factory.create(plugin_config)?; + + self.registry + .register_multi_handler( + instance.plugin, + plugin_config.clone(), + instance.handlers, + ) + .map_err(|msg| PluginError::Config { message: msg })?; + + info!( + "Registered plugin '{}' (kind: '{}') for hooks: {:?}", + plugin_config.name, plugin_config.kind, plugin_config.hooks + ); + } + + // Clear routing cache — config changed + self.clear_routing_cache(); + + // Store config for route resolution + self.cpex_config = Some(cpex_config); + + Ok(()) + } + + /// Create a PluginManager from a parsed config (convenience). + /// + /// Uses the passed factory registry for initial instantiation. + /// Note: for route-level config overrides to create new instances + /// at runtime, use `register_factory()` + `load_config()` instead + /// so the manager owns the factories. + pub fn from_config( + cpex_config: CpexConfig, + factories: &PluginFactoryRegistry, + ) -> Result { + let mut manager = Self::new(ManagerConfig::default()); + + // Instantiate and register each plugin + for plugin_config in &cpex_config.plugins { + let factory = factories.get(&plugin_config.kind).ok_or_else(|| { + PluginError::Config { + message: format!( + "no factory registered for plugin kind '{}' (plugin '{}')", + plugin_config.kind, plugin_config.name + ), + } + })?; + + let instance = factory.create(plugin_config)?; + + manager + .registry + .register_multi_handler( + instance.plugin, + plugin_config.clone(), + instance.handlers, + ) + .map_err(|msg| PluginError::Config { message: msg })?; + } + + // Update executor from config settings + manager.executor = Executor::new(ExecutorConfig { + timeout_seconds: cpex_config.plugin_settings.plugin_timeout, + short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, + }); + + manager.cpex_config = Some(cpex_config); + Ok(manager) + } + // ----------------------------------------------------------------------- // Registration // ----------------------------------------------------------------------- @@ -244,6 +451,9 @@ impl PluginManager { plugin_name, message: format!("initialization failed: {}", e), source: Some(Box::new(e)), + code: None, + details: std::collections::HashMap::new(), + proto_error_code: None, }); } @@ -304,33 +514,51 @@ impl PluginManager { /// /// # Returns /// - /// A `PipelineResult` with the final payload, extensions, violation, - /// and the updated context table. + /// A tuple of `(PipelineResult, BackgroundTasks)`. The result + /// contains the final payload, extensions, violation, and context + /// table. Background tasks can be awaited or dropped. pub async fn invoke_by_name( &self, hook_name: &str, payload: Box, extensions: Extensions, context_table: Option, - ) -> PipelineResult { + ) -> (PipelineResult, BackgroundTasks) { let hook_type = HookType::new(hook_name); - let entries = self.registry.entries_for_hook(&hook_type); + let all_entries = self.registry.entries_for_hook(&hook_type); + + if all_entries.is_empty() { + return ( + PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), + ); + } + + let entries = self.filter_entries_by_route(all_entries, &extensions, hook_name); if entries.is_empty() { - return PipelineResult::allowed_with( - payload, - extensions, - context_table.unwrap_or_default(), + return ( + PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), ); } self.executor - .execute(entries, payload, extensions, context_table) + .execute(&entries, payload, extensions, context_table) .await } // ----------------------------------------------------------------------- // Hook Invocation — Typed (invoke::) + // ----------------------------------------------------------------------- /// Invoke a typed hook. @@ -340,6 +568,11 @@ impl PluginManager { /// Dispatch goes through the same registry and 5-phase executor /// as `invoke_by_name()`. /// + /// When routing is enabled, the entity is identified from + /// `extensions.meta` (entity_type + entity_name). Only plugins + /// matching the resolved route fire. When routing is disabled + /// or meta is absent, all registered plugins fire. + /// /// # Type Parameters /// /// - `H` — the hook type (implements `HookTypeDef`). @@ -347,38 +580,239 @@ impl PluginManager { /// # Arguments /// /// * `payload` — the typed payload. - /// * `extensions` — the full extensions. + /// * `extensions` — the full extensions (includes meta for routing). /// * `context_table` — optional context table from a previous hook. /// /// # Returns /// - /// A `PipelineResult` with the final payload (type-erased — - /// caller downcasts via `as_any()`), extensions, violation, and - /// the updated context table. + /// A tuple of `(PipelineResult, BackgroundTasks)`. pub async fn invoke( &self, payload: H::Payload, extensions: Extensions, context_table: Option, - ) -> PipelineResult { + ) -> (PipelineResult, BackgroundTasks) { let hook_type = HookType::new(H::NAME); - let entries = self.registry.entries_for_hook(&hook_type); + let all_entries = self.registry.entries_for_hook(&hook_type); + + if all_entries.is_empty() { + let boxed: Box = Box::new(payload); + return ( + PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), + ); + } + + let entries = self.filter_entries_by_route(all_entries, &extensions, H::NAME); if entries.is_empty() { let boxed: Box = Box::new(payload); - return PipelineResult::allowed_with( - boxed, - extensions, - context_table.unwrap_or_default(), + return ( + PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), ); } let boxed: Box = Box::new(payload); self.executor - .execute(entries, boxed, extensions, context_table) + .execute(&entries, boxed, extensions, context_table) .await } + // ----------------------------------------------------------------------- + // Route Filtering + // ----------------------------------------------------------------------- + + /// Filter hook entries based on route resolution, with caching. + /// + /// When routing is enabled and extensions.meta provides entity + /// identification, resolves the route and returns only the entries + /// for plugins that match. Results are cached by + /// `(entity_type, entity_name, hook_name, scope)` — subsequent + /// calls for the same key return an `Arc` to the cached entries + /// (refcount bump, no data copy). + /// + /// When routing is disabled or meta is absent, returns all entries. + fn filter_entries_by_route( + &self, + entries: &[crate::registry::HookEntry], + extensions: &Extensions, + hook_name: &str, + ) -> Arc> { + // If no config or routing disabled, return all + let cpex_config = match &self.cpex_config { + Some(c) if c.routing_enabled() => c, + _ => return Arc::new(entries.to_vec()), + }; + + // Extract entity info from meta extension + let meta = match &extensions.meta { + Some(m) => m, + None => return Arc::new(entries.to_vec()), + }; + + let (entity_type, entity_name) = match (&meta.entity_type, &meta.entity_name) { + (Some(t), Some(n)) => (t.as_str(), n.as_str()), + _ => return Arc::new(entries.to_vec()), + }; + + let request_scope = meta.scope.as_deref(); + + // Fast path: zero-allocation cache lookup with raw_entry + let hash = { + use std::hash::BuildHasher; + let mut hasher = self.cache_hasher.build_hasher(); + entity_type.hash(&mut hasher); + entity_name.hash(&mut hasher); + hook_name.hash(&mut hasher); + request_scope.hash(&mut hasher); + hasher.finish() + }; + { + let cache = self.route_cache.read().unwrap(); + if let Some((_, cached)) = cache.raw_entry().from_hash(hash, |key| { + key.entity_type == entity_type + && key.entity_name == entity_name + && key.hook_name == hook_name + && key.scope.as_deref() == request_scope + }) { + return Arc::clone(cached); + } + } + + // Slow path: resolve, filter, and cache (allocations only here) + let resolved = config::resolve_plugins_for_entity( + cpex_config, + entity_type, + entity_name, + request_scope, + &meta.tags, + ); + + // Filter entries to resolved plugins, preserving resolution order. + // If a plugin has config overrides and we have a factory for its kind, + // create a new instance with the merged config. + let mut filtered = Vec::new(); + for resolved_plugin in &resolved { + if let Some(entry) = entries.iter().find(|e| e.plugin_ref.name() == resolved_plugin.name) { + if let Some(overrides) = &resolved_plugin.config_overrides { + // Try to create an override instance + if let Some(override_entry) = self.create_override_instance(entry, overrides) { + filtered.push(override_entry); + continue; + } + } + filtered.push(entry.clone()); + } + } + + let cached = Arc::new(filtered); + + // Store in cache — owned key allocated only on cache miss + let cache_key = RouteCacheKey { + entity_type: entity_type.to_string(), + entity_name: entity_name.to_string(), + hook_name: hook_name.to_string(), + scope: meta.scope.clone(), + }; + { + let mut cache = self.route_cache.write().unwrap(); + cache.insert(cache_key, Arc::clone(&cached)); + } + + cached + } + + /// Create an override plugin instance with merged config. + /// + /// When a route overrides a plugin's config, we create a new + /// instance via the factory with the merged config. Returns + /// None if no factory is available for the plugin's kind. + fn create_override_instance( + &self, + base_entry: &crate::registry::HookEntry, + overrides: &serde_json::Value, + ) -> Option { + let base_config = base_entry.plugin_ref.trusted_config(); + let kind = &base_config.kind; + + let factory = self.factories.get(kind)?; + + // Merge: start with base config, overlay with overrides + let mut merged_config = base_config.clone(); + if let Some(override_config) = overrides.get("config") { + // Merge the plugin-specific config section + if let Some(base_plugin_config) = &merged_config.config { + let mut merged = base_plugin_config.clone(); + if let (Some(base_obj), Some(override_obj)) = + (merged.as_object_mut(), override_config.as_object()) + { + for (key, value) in override_obj { + base_obj.insert(key.clone(), value.clone()); + } + } + merged_config.config = Some(merged); + } else { + merged_config.config = Some(override_config.clone()); + } + } + + // Create new instance with merged config + let target_hook = base_entry.handler.hook_type_name(); + match factory.create(&merged_config) { + Ok(instance) => { + // Find the handler matching the current hook + let handler = instance + .handlers + .into_iter() + .find(|(name, _)| *name == target_hook) + .map(|(_, h)| h); + + if let Some(handler) = handler { + let plugin_ref = + crate::registry::PluginRef::new(instance.plugin, merged_config); + Some(crate::registry::HookEntry { + plugin_ref, + handler, + }) + } else { + warn!( + "Override instance for '{}' has no handler for hook '{}'", + base_config.name, target_hook + ); + None + } + } + Err(e) => { + error!( + "Failed to create override instance for '{}': {}", + base_config.name, e + ); + None // fall back to base instance + } + } + } + + /// Clear the routing cache. Call when config is reloaded or + /// plugins are registered/unregistered. + pub fn clear_routing_cache(&self) { + let mut cache = self.route_cache.write().unwrap(); + cache.clear(); + } + + /// Number of entries in the routing cache. + pub fn routing_cache_size(&self) -> usize { + self.route_cache.read().unwrap().len() + } + // ----------------------------------------------------------------------- // Query Methods // ----------------------------------------------------------------------- @@ -511,6 +945,9 @@ mod tests { plugin_name: "error-plugin".into(), message: "simulated failure".into(), source: None, + code: None, + details: std::collections::HashMap::new(), + proto_error_code: None, }) } @@ -574,12 +1011,12 @@ mod tests { }); - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; - assert!(result.allowed); - assert!(result.payload.is_some()); + assert!(result.continue_processing); + assert!(result.modified_payload.is_some()); } #[tokio::test] @@ -597,11 +1034,11 @@ mod tests { }); - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; - assert!(result.allowed); + assert!(result.continue_processing); } #[tokio::test] @@ -618,11 +1055,11 @@ mod tests { }); - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; - assert!(!result.allowed); + assert!(!result.continue_processing); assert_eq!(result.violation.as_ref().unwrap().code, "denied"); } @@ -640,11 +1077,11 @@ mod tests { }; - let result = mgr + let (result, _) = mgr .invoke::(payload, Extensions::default(), None) .await; - assert!(result.allowed); + assert!(result.continue_processing); } #[tokio::test] @@ -687,12 +1124,12 @@ mod tests { }); - let result = mgr + let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; // Audit mode — deny is suppressed, pipeline continues - assert!(result.allowed); + assert!(result.continue_processing); } #[tokio::test] @@ -718,8 +1155,8 @@ mod tests { // First invocation — flaky plugin errors, gets disabled, pipeline continues // because on_error is Disable (not Fail). allow-plugin still runs. let payload: Box = Box::new(TestPayload { value: "first".into() }); - let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; - assert!(result.allowed); + let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(result.continue_processing); // Verify the plugin is now disabled let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); @@ -729,8 +1166,8 @@ mod tests { // Second invocation — flaky plugin should be skipped entirely // (group_by_mode filters it out). Only allow-plugin runs. let payload2: Box = Box::new(TestPayload { value: "second".into() }); - let result2 = mgr.invoke_by_name("test_hook", payload2, Extensions::default(), None).await; - assert!(result2.allowed); + let (result2, _) = mgr.invoke_by_name("test_hook", payload2, Extensions::default(), None).await; + assert!(result2.continue_processing); } #[tokio::test] @@ -750,8 +1187,8 @@ mod tests { // First invocation — plugin errors, ignored, pipeline continues let payload: Box = Box::new(TestPayload { value: "test".into() }); - let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; - assert!(result.allowed); + let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(result.continue_processing); // Plugin should NOT be disabled — still in its original mode let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); @@ -776,8 +1213,8 @@ mod tests { // Invocation — plugin errors, pipeline halts with a violation let payload: Box = Box::new(TestPayload { value: "test".into() }); - let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; - assert!(!result.allowed); + let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(!result.continue_processing); assert_eq!(result.violation.as_ref().unwrap().code, "plugin_error"); assert_eq!( result.violation.as_ref().unwrap().plugin_name.as_deref(), @@ -848,10 +1285,10 @@ mod tests { let payload = TestPayload { value: "original".into() }; - let result = mgr.invoke::(payload, Extensions::default(), None).await; + let (result, _) = mgr.invoke::(payload, Extensions::default(), None).await; - assert!(result.allowed); - let final_payload = result.payload.unwrap(); + assert!(result.continue_processing); + let final_payload = result.modified_payload.unwrap(); let typed = final_payload.as_any().downcast_ref::().unwrap(); assert_eq!(typed.value, "original_transformed"); } @@ -902,10 +1339,10 @@ mod tests { let start = std::time::Instant::now(); let payload: Box = Box::new(TestPayload { value: "test".into() }); - let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; let elapsed = start.elapsed(); - assert!(result.allowed); + assert!(result.continue_processing); assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2); // If they ran in parallel, total time should be ~50ms, not ~100ms assert!(elapsed.as_millis() < 90, "concurrent plugins ran serially: {}ms", elapsed.as_millis()); @@ -932,11 +1369,11 @@ mod tests { let start = std::time::Instant::now(); let payload: Box = Box::new(TestPayload { value: "test".into() }); - let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; let elapsed = start.elapsed(); // Should have timed out and denied (on_error: Fail) - assert!(!result.allowed); + assert!(!result.continue_processing); assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout"); // Should have returned in ~1s, not 5s assert!(elapsed.as_secs() < 3, "timeout didn't fire: {}s", elapsed.as_secs()); @@ -980,14 +1417,15 @@ mod tests { mgr.initialize().await.unwrap(); let payload: Box = Box::new(TestPayload { value: "test".into() }); - let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let (result, bg) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; // Pipeline should return immediately — before the background task finishes - assert!(result.allowed); + assert!(result.continue_processing); assert!(!TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task completed before pipeline returned"); - // Wait for the background task to finish - tokio::time::sleep(std::time::Duration::from_millis(300)).await; + // Wait for background tasks using wait_for_background_tasks() + let errors = bg.wait_for_background_tasks().await; + assert!(errors.is_empty(), "background task had errors: {:?}", errors); assert!(TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task never completed"); } @@ -1052,9 +1490,9 @@ mod tests { mgr.initialize().await.unwrap(); let payload: Box = Box::new(TestPayload { value: "test".into() }); - let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; - assert!(result.allowed); + assert!(result.continue_processing); assert!( saw_writer.load(std::sync::atomic::Ordering::SeqCst), "reader plugin did not see writer's global_state change" @@ -1098,8 +1536,8 @@ mod tests { // First invocation — no context table, starts fresh let payload: Box = Box::new(TestPayload { value: "first".into() }); - let result1 = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; - assert!(result1.allowed); + let (result1, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(result1.continue_processing); // Check call_count = 1 in the returned context table let table = &result1.context_table; @@ -1108,14 +1546,792 @@ mod tests { // Second invocation — pass the context table from the first call let payload2: Box = Box::new(TestPayload { value: "second".into() }); - let result2 = mgr.invoke_by_name( + let (result2, _) = mgr.invoke_by_name( "test_hook", payload2, Extensions::default(), Some(result1.context_table), ).await; - assert!(result2.allowed); + assert!(result2.continue_processing); // call_count should now be 2 — local_state persisted across invocations let table2 = &result2.context_table; let ctx2 = table2.values().next().expect("context table should have one entry"); assert_eq!(ctx2.get_local("call_count").unwrap().as_u64().unwrap(), 2); } + + // -- Factory-based tests -- + + /// A test factory that creates AllowPlugin instances. + struct AllowPluginFactory; + + impl crate::factory::PluginFactory for AllowPluginFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result { + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) + } + } + + /// A test factory that creates DenyPlugin instances. + struct DenyPluginFactory; + + impl crate::factory::PluginFactory for DenyPluginFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result { + let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) + } + } + + #[tokio::test] + async fn test_from_config_creates_manager() { + let yaml = r#" +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 + +plugin_settings: + plugin_timeout: 60 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.plugin_count(), 1); + assert!(mgr.has_hooks_for("test_hook")); + } + + #[tokio::test] + async fn test_from_config_invokes_correctly() { + let yaml = r#" +plugins: + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/deny", Box::new(DenyPluginFactory)); + + let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + // context_table = None (first invocation) + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "denied"); + } + + #[tokio::test] + async fn test_from_config_unknown_kind_rejected() { + let yaml = r#" +plugins: + - name: mystery + kind: unknown/type + hooks: [test_hook] +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let factories = PluginFactoryRegistry::new(); // empty — no factories + + let result = PluginManager::from_config(cpex_config, &factories); + match result { + Err(e) => assert!(e.to_string().contains("no factory registered"), "got: {}", e), + Ok(_) => panic!("expected error for unknown kind"), + } + } + + #[tokio::test] + async fn test_from_config_multiple_plugins() { + let yaml = r#" +plugins: + - name: gate + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 5 + - name: fallback + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + factories.register("test/deny", Box::new(DenyPluginFactory)); + + let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.plugin_count(), 2); + + // Deny plugin has higher priority (5 < 10), so it fires first + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + // context_table = None (first invocation) + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(!result.continue_processing); // gate denied before fallback could allow + } + + // -- Routing cache tests -- + + #[tokio::test] + async fn test_routing_cache_populated_on_first_invoke() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.routing_cache_size(), 0); + + // First invoke — populates cache + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let ext = Extensions { + meta: Some(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + }), + ..Default::default() + }; + // context_table = None (first invocation) + mgr.invoke_by_name("test_hook", payload, ext, None).await; + + assert_eq!(mgr.routing_cache_size(), 1); + + // Second invoke — cache hit, still size 1 + let payload2: Box = Box::new(TestPayload { value: "test2".into() }); + let ext2 = Extensions { + meta: Some(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + }), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", payload2, ext2, None).await; + + assert_eq!(mgr.routing_cache_size(), 1); // cache hit — no new entry + } + + #[tokio::test] + async fn test_routing_cache_different_entities_separate() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation + - tool: send_email +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Invoke for get_compensation + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let e1 = Extensions { + meta: Some(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + }), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p1, e1, None).await; + + // Invoke for send_email + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let e2 = Extensions { + meta: Some(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("send_email".into()), + ..Default::default() + }), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p2, e2, None).await; + + assert_eq!(mgr.routing_cache_size(), 2); + } + + #[tokio::test] + async fn test_routing_cache_cleared() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let ext = Extensions { + meta: Some(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + }), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", payload, ext, None).await; + assert_eq!(mgr.routing_cache_size(), 1); + + mgr.clear_routing_cache(); + assert_eq!(mgr.routing_cache_size(), 0); + } + + #[tokio::test] + async fn test_routing_cache_scope_creates_separate_entries() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Same entity, different scopes → separate cache entries + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let e1 = Extensions { + meta: Some(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + scope: Some("hr-server".into()), + ..Default::default() + }), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p1, e1, None).await; + + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let e2 = Extensions { + meta: Some(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + scope: Some("billing-server".into()), + ..Default::default() + }), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p2, e2, None).await; + + assert_eq!(mgr.routing_cache_size(), 2); // different scopes → different cache entries + } + + // -- Override instance tests -- + + #[tokio::test] + async fn test_route_override_creates_new_instance() { + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: rate_limiter + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 + config: + max_requests: 100 +routes: + - tool: get_compensation + plugins: + - rate_limiter: + config: + max_requests: 10 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + // Use register_factory + load_config so manager owns factories + let mut mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // Invoke with routing — should create override instance + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let ext = Extensions { + meta: Some(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + }), + ..Default::default() + }; + // context_table = None (first invocation) + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, ext, None) + .await; + + // Plugin executed (allow plugin returns allowed) + assert!(result.continue_processing); + // Cache populated + assert_eq!(mgr.routing_cache_size(), 1); + } + + #[tokio::test] + async fn test_register_factory_then_load_config() { + let yaml = r#" +plugins: + - name: my_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 + +plugin_settings: + plugin_timeout: 45 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mut mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.plugin_count(), 1); + assert!(mgr.has_hooks_for("test_hook")); + + let payload: Box = Box::new(TestPayload { value: "t".into() }); + // context_table = None (first invocation) + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + } + + // -- End-to-end routing tests -- + + /// Helper to build meta extensions for routing tests. + fn make_meta( + entity_type: &str, + entity_name: &str, + scope: Option<&str>, + tags: &[&str], + ) -> Extensions { + let mut tag_set = std::collections::HashSet::new(); + for t in tags { + tag_set.insert(t.to_string()); + } + Extensions { + meta: Some(crate::hooks::payload::MetaExtension { + entity_type: Some(entity_type.into()), + entity_name: Some(entity_name.into()), + scope: scope.map(String::from), + tags: tag_set, + ..Default::default() + }), + ..Default::default() + } + } + + #[tokio::test] + async fn test_routing_full_flow_different_tools_different_plugins() { + // Setup: identity fires for all, apl_policy fires for pii tools, + // rate_limiter fires only for get_compensation route + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [identity] + pii: + plugins: [apl_policy] +plugins: + - name: identity + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 1 + - name: apl_policy + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 + - name: rate_limiter + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 5 +routes: + - tool: get_compensation + meta: + tags: [pii] + plugins: + - rate_limiter + - tool: send_email + plugins: + - rate_limiter +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // get_compensation: identity (all) + apl_policy (pii tag) + rate_limiter (route) + // apl_policy denies → overall denied + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let (r1, _) = mgr + .invoke_by_name("test_hook", p1, make_meta("tool", "get_compensation", None, &[]), None) + .await; + assert!(!r1.continue_processing); // apl_policy (deny) fires due to pii tag + + // send_email: identity (all) + rate_limiter (route) — no pii tag + // both allow → overall allowed + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let (r2, _) = mgr + .invoke_by_name("test_hook", p2, make_meta("tool", "send_email", None, &[]), None) + .await; + assert!(r2.continue_processing); // no deny plugin fires + } + + #[tokio::test] + async fn test_routing_disabled_fires_all_plugins() { + // Same plugins but routing disabled — all fire regardless of entity + let yaml = r#" +plugins: + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 + - name: allower + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 20 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Even with meta, routing disabled → all plugins fire → denier wins + let p: Box = Box::new(TestPayload { value: "t".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", p, make_meta("tool", "anything", None, &[]), None) + .await; + assert!(!result.continue_processing); // denier fires (all plugins active) + } + + #[tokio::test] + async fn test_routing_no_meta_fires_all_plugins() { + // Routing enabled but no meta on extensions → fallback to all + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allower] +plugins: + - name: allower + kind: test/allow + hooks: [test_hook] + mode: sequential + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation + plugins: + - denier +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // No meta → all plugins fire (both allower and denier) + let p: Box = Box::new(TestPayload { value: "t".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", p, Extensions::default(), None) + .await; + // denier has default priority 100, allower has default 100 — order depends on registration + // but at least both fire (not filtered by routing) + // We can't assert allow/deny specifically since both run — just check it executed + assert!(result.continue_processing || !result.continue_processing); // both plugins fired + } + + #[tokio::test] + async fn test_routing_wildcard_catches_unmatched() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [identity] +plugins: + - name: identity + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 1 + - name: specific_plugin + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 + - name: fallback_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 +routes: + - tool: get_compensation + plugins: + - specific_plugin + - tool: "*" + plugins: + - fallback_plugin +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // get_compensation matches exact route → specific_plugin (deny) + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let (r1, _) = mgr + .invoke_by_name("test_hook", p1, make_meta("tool", "get_compensation", None, &[]), None) + .await; + assert!(!r1.continue_processing); // specific_plugin denies + + // unknown_tool matches wildcard → fallback_plugin (allow) + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let (r2, _) = mgr + .invoke_by_name("test_hook", p2, make_meta("tool", "unknown_tool", None, &[]), None) + .await; + assert!(r2.continue_processing); // fallback_plugin allows + } + + #[tokio::test] + async fn test_routing_host_tags_activate_policy_groups() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [identity] + urgent: + plugins: [denier] +plugins: + - name: identity + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 1 + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Without urgent tag → only identity fires → allowed + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let (r1, _) = mgr + .invoke_by_name("test_hook", p1, make_meta("tool", "get_compensation", None, &[]), None) + .await; + assert!(r1.continue_processing); + + // Clear cache so new tags take effect + mgr.clear_routing_cache(); + + // With urgent tag from host → denier also fires → denied + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let (r2, _) = mgr + .invoke_by_name("test_hook", p2, make_meta("tool", "get_compensation", None, &["urgent"]), None) + .await; + assert!(!r2.continue_processing); + } + + #[tokio::test] + async fn test_routing_works_with_typed_invoke() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allower] + pii: + plugins: [denier] +plugins: + - name: allower + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 1 + - name: denier + kind: test/deny + hooks: [test_hook] + mode: sequential + priority: 10 +routes: + - tool: get_compensation + meta: + tags: [pii] + - tool: send_email +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut mgr = PluginManager::default(); + mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); + mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Typed invoke for get_compensation — pii tag activates denier → denied + let (r1, _) = mgr + .invoke::( + TestPayload { value: "t".into() }, + make_meta("tool", "get_compensation", None, &[]), + None, + ) + .await; + assert!(!r1.continue_processing); + + // Typed invoke for send_email — no pii tag → only allower → allowed + let (r2, _) = mgr + .invoke::( + TestPayload { value: "t".into() }, + make_meta("tool", "send_email", None, &[]), + None, + ) + .await; + assert!(r2.continue_processing); + } } diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs index 9d4a00a6..b9c13f11 100644 --- a/crates/cpex-core/src/plugin.rs +++ b/crates/cpex-core/src/plugin.rs @@ -89,13 +89,19 @@ pub trait Plugin: Send + Sync { /// /// Called before any hook invocations. Use this to establish /// connections, load resources, or validate configuration. - async fn initialize(&self) -> Result<(), PluginError>; + /// Default implementation does nothing. + async fn initialize(&self) -> Result<(), PluginError> { + Ok(()) + } /// Graceful shutdown. /// /// Called once during teardown. Use this to flush buffers, close /// connections, or release resources. - async fn shutdown(&self) -> Result<(), PluginError>; + /// Default implementation does nothing. + async fn shutdown(&self) -> Result<(), PluginError> { + Ok(()) + } } // --------------------------------------------------------------------------- diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index cbc88a9c..fce0466c 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -278,6 +278,66 @@ impl PluginRegistry { self.register_for_names_inner(plugin, config, handler, names) } + /// Register a plugin with a handler for multiple hook names. + /// + /// Like `register_for_names` but without requiring a `HookTypeDef` + /// type parameter. Used by the config-driven factory path where + /// the hook type is not known at compile time — the factory + /// provides the handler directly. + pub fn register_for_names_with_handler( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + names: &[&str], + ) -> Result<(), String> { + self.register_for_names_inner(plugin, config, handler, names) + } + + + /// Register a plugin with multiple handlers, each for a specific hook. + /// + /// Used when a plugin implements multiple hook types with different + /// payloads (e.g., `ToolPreInvoke` and `ToolPostInvoke`). Each + /// handler is registered under its paired hook name. + /// + /// The plugin is registered once in the name index. Each handler + /// gets its own `HookEntry` in the hook index under the specified name. + pub fn register_multi_handler( + &mut self, + plugin: Arc, + config: PluginConfig, + handlers: Vec<(&str, Arc)>, + ) -> Result<(), String> { + let name = config.name.clone(); + + if self.plugins.contains_key(&name) { + return Err(format!("plugin '{}' is already registered", name)); + } + + let plugin_ref = PluginRef::new(plugin, config); + + for (hook_name, handler) in &handlers { + let hook_type = HookType::new(*hook_name); + let entry = HookEntry { + plugin_ref: plugin_ref.clone(), + handler: Arc::clone(handler), + }; + self.hook_index.entry(hook_type).or_default().push(entry); + } + + // Sort each affected hook's entry list by trusted priority + for (hook_name, _) in &handlers { + let hook_type = HookType::new(*hook_name); + if let Some(entries) = self.hook_index.get_mut(&hook_type) { + entries.sort_by_key(|e| e.plugin_ref.priority()); + } + } + + self.plugins.insert(name, plugin_ref); + Ok(()) + } + /// Internal: register handler under one or more hook names. fn register_for_names_inner( &mut self, From e9a7ff4867bd1e556b1f2391f8d707edc7e3bd4e Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 4 May 2026 12:58:52 -0600 Subject: [PATCH 03/11] feat: RUST with CMF and extensions. (#44) * feat: initial revision rust core. Signed-off-by: Teryl Taylor * fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor * feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor * feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor * fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor * feat: RUST CMF initial revision. Signed-off-by: Teryl Taylor * feat: added invoke named support, added constants, fixed reviewed code. Signed-off-by: Teryl Taylor * feat: added owned extensions and did some refactoring. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: Teryl Taylor Co-authored-by: Frederico Araujo --- Cargo.toml | 2 +- crates/cpex-core/examples/README.md | 37 + .../examples/cmf_capabilities_demo.rs | 435 +++++++++ .../examples/cmf_capabilities_demo.yaml | 50 ++ crates/cpex-core/examples/plugin_demo.rs | 16 +- crates/cpex-core/src/cmf/constants.rs | 65 ++ crates/cpex-core/src/cmf/content.rs | 486 ++++++++++ crates/cpex-core/src/cmf/enums.rs | 176 ++++ crates/cpex-core/src/cmf/message.rs | 462 ++++++++++ crates/cpex-core/src/cmf/mod.rs | 100 +++ crates/cpex-core/src/cmf/view.rs | 848 ++++++++++++++++++ crates/cpex-core/src/executor.rs | 116 ++- crates/cpex-core/src/extensions/agent.rs | 60 ++ crates/cpex-core/src/extensions/completion.rs | 71 ++ crates/cpex-core/src/extensions/container.rs | 532 +++++++++++ crates/cpex-core/src/extensions/delegation.rs | 161 ++++ crates/cpex-core/src/extensions/filter.rs | 549 ++++++++++++ crates/cpex-core/src/extensions/framework.rs | 38 + crates/cpex-core/src/extensions/guarded.rs | 141 +++ crates/cpex-core/src/extensions/http.rs | 200 +++++ crates/cpex-core/src/extensions/llm.rs | 27 + crates/cpex-core/src/extensions/mcp.rs | 115 +++ crates/cpex-core/src/extensions/meta.rs | 45 + crates/cpex-core/src/extensions/mod.rs | 52 ++ crates/cpex-core/src/extensions/monotonic.rs | 183 ++++ crates/cpex-core/src/extensions/provenance.rs | 27 + crates/cpex-core/src/extensions/request.rs | 35 + crates/cpex-core/src/extensions/security.rs | 337 +++++++ crates/cpex-core/src/extensions/tiers.rs | 100 +++ crates/cpex-core/src/hooks/adapter.rs | 4 +- crates/cpex-core/src/hooks/mod.rs | 4 +- crates/cpex-core/src/hooks/payload.rs | 100 +-- crates/cpex-core/src/hooks/trait_def.rs | 22 +- crates/cpex-core/src/lib.rs | 3 + crates/cpex-core/src/manager.rs | 371 +++++++- crates/cpex-core/src/plugin.rs | 2 +- crates/cpex-core/src/registry.rs | 8 +- crates/cpex-sdk/src/lib.rs | 13 +- 38 files changed, 5826 insertions(+), 167 deletions(-) create mode 100644 crates/cpex-core/examples/cmf_capabilities_demo.rs create mode 100644 crates/cpex-core/examples/cmf_capabilities_demo.yaml create mode 100644 crates/cpex-core/src/cmf/constants.rs create mode 100644 crates/cpex-core/src/cmf/content.rs create mode 100644 crates/cpex-core/src/cmf/enums.rs create mode 100644 crates/cpex-core/src/cmf/message.rs create mode 100644 crates/cpex-core/src/cmf/mod.rs create mode 100644 crates/cpex-core/src/cmf/view.rs create mode 100644 crates/cpex-core/src/extensions/agent.rs create mode 100644 crates/cpex-core/src/extensions/completion.rs create mode 100644 crates/cpex-core/src/extensions/container.rs create mode 100644 crates/cpex-core/src/extensions/delegation.rs create mode 100644 crates/cpex-core/src/extensions/filter.rs create mode 100644 crates/cpex-core/src/extensions/framework.rs create mode 100644 crates/cpex-core/src/extensions/guarded.rs create mode 100644 crates/cpex-core/src/extensions/http.rs create mode 100644 crates/cpex-core/src/extensions/llm.rs create mode 100644 crates/cpex-core/src/extensions/mcp.rs create mode 100644 crates/cpex-core/src/extensions/meta.rs create mode 100644 crates/cpex-core/src/extensions/mod.rs create mode 100644 crates/cpex-core/src/extensions/monotonic.rs create mode 100644 crates/cpex-core/src/extensions/provenance.rs create mode 100644 crates/cpex-core/src/extensions/request.rs create mode 100644 crates/cpex-core/src/extensions/security.rs create mode 100644 crates/cpex-core/src/extensions/tiers.rs diff --git a/Cargo.toml b/Cargo.toml index 8ee43bc0..47acca9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ authors = ["Teryl Taylor"] [workspace.dependencies] tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } +serde = { version = "1", features = ["derive", "rc"] } serde_yaml = "0.9" serde_json = "1" async-trait = "0.1" diff --git a/crates/cpex-core/examples/README.md b/crates/cpex-core/examples/README.md index 9be92c2d..c3962e31 100644 --- a/crates/cpex-core/examples/README.md +++ b/crates/cpex-core/examples/README.md @@ -41,3 +41,40 @@ The demo runs five scenarios against three registered plugins: - `plugin_demo.rs` — Rust source with plugins, factories, and main - `plugin_demo.yaml` — YAML config with plugins, policy groups, and routes + +--- + +## cmf_capabilities_demo + +Demonstrates CMF messages with capability-gated extension access. Shows how different plugins see different views of the same extensions based on their declared capabilities. + +### What it demonstrates + +- **CMF Message** — typed content parts (`Text`, `ToolCall`) with the standard CMF format +- **Capability gating** — plugins declare capabilities in YAML config; the executor filters extensions per plugin +- **Security labels** — `MonotonicSet` (add-only, no remove at compile time) +- **Guarded HTTP headers** — `.read()` is free, `.write(token)` requires a `WriteToken` +- **COW copy** — `extensions.cow_copy()` for plugins that need to modify; zero-cost for read-only plugins +- **Write tokens** — executor sets tokens based on capabilities; propagated through `cow_copy()` +- **Three capability levels** — identity-checker (security), header-injector (http + labels), audit-logger (http + labels read-only) + +### Running + +From the workspace root: + +``` +cargo run --example cmf_capabilities_demo +``` + +### What each plugin sees + +| Plugin | Capabilities | Security Labels | Subject | HTTP Headers | Can Write | +|--------|-------------|-----------------|---------|--------------|-----------| +| identity-checker | read_labels, read_subject, read_roles | visible | visible (id + roles) | hidden | no | +| header-injector | read_headers, write_headers, append_labels | visible | hidden | visible | yes (headers + labels) | +| audit-logger | read_headers, read_labels | visible | hidden | visible | no (audit mode) | + +### Files + +- `cmf_capabilities_demo.rs` — Rust source with CMF plugins and capability-gated access +- `cmf_capabilities_demo.yaml` — YAML config with per-plugin capabilities diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.rs b/crates/cpex-core/examples/cmf_capabilities_demo.rs new file mode 100644 index 00000000..1257f03d --- /dev/null +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -0,0 +1,435 @@ +// CMF Capabilities Demo +// +// Demonstrates: +// 1. CMF Message with typed content parts (tool call) +// 2. Extensions with security, HTTP, and meta populated +// 3. Config-driven capability gating — plugins only see what they declare +// 4. COW copy for extension modification with write tokens +// 5. MonotonicSet labels (add-only, no remove) +// 6. Guarded HTTP headers (read free, write needs token) +// +// Run with: cargo run --example cmf_capabilities_demo + +use std::sync::Arc; + +use async_trait::async_trait; +use cpex_core::cmf::{ContentPart, CmfHook, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::{ + HttpExtension, RequestExtension, SecurityExtension, +}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::{Extensions, MetaExtension}; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +// --------------------------------------------------------------------------- +// Plugin: IdentityChecker +// Has read_security, read_labels, read_subject, read_roles capabilities. +// Checks if the caller has the required role. +// --------------------------------------------------------------------------- + +struct IdentityChecker { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for IdentityChecker { + fn config(&self) -> &PluginConfig { &self.cfg } +} + +impl HookHandler for IdentityChecker { + fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Determine if this is pre or post invoke based on message content + let is_result = payload.message.is_tool_result(); + + if is_result { + // POST-INVOKE: verify the tool result came from an authorized call + let tool_name = payload.message.get_tool_results() + .first() + .map(|tr| tr.tool_name.as_str()) + .unwrap_or("unknown"); + println!(" [identity-checker] POST-INVOKE: verifying result from '{}'", tool_name); + + if let Some(ref security) = extensions.security { + if let Some(ref subject) = security.subject { + println!(" [identity-checker] Result authorized for subject: {:?}", subject.id); + } + } + println!(" [identity-checker] POST-INVOKE ALLOWED"); + } else { + // PRE-INVOKE: check caller identity and roles + let tool_name = payload.message.get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + println!(" [identity-checker] PRE-INVOKE: checking identity for '{}'", tool_name); + + if let Some(ref security) = extensions.security { + let labels: Vec<&String> = security.labels.iter().collect(); + println!(" [identity-checker] Security labels: {:?}", labels); + + if let Some(ref subject) = security.subject { + println!(" [identity-checker] Subject: {:?}, Roles: {:?}", + subject.id, subject.roles.iter().collect::>()); + + if security.has_label("PII") && !subject.roles.contains("hr_admin") { + return PluginResult::deny(PluginViolation::new( + "insufficient_role", + format!("Tool '{}' requires 'hr_admin' role for PII data", tool_name), + )); + } + } + } + + if extensions.http.is_some() { + println!(" [identity-checker] WARNING: HTTP visible (unexpected!)"); + } else { + println!(" [identity-checker] HTTP: not visible (correct — no read_headers)"); + } + println!(" [identity-checker] PRE-INVOKE ALLOWED"); + } + + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Plugin: HeaderInjector +// Has read_headers, write_headers, append_labels capabilities. +// Uses COW to add a security label and inject a header. +// --------------------------------------------------------------------------- + +struct HeaderInjector { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for HeaderInjector { + fn config(&self) -> &PluginConfig { &self.cfg } +} + +impl HookHandler for HeaderInjector { + fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Can see HTTP (has read_headers) + if let Some(ref http) = extensions.http { + println!(" [header-injector] HTTP headers visible: {:?}", http.request_headers); + } + + // Can NOT see security subject (no read_subject) + if let Some(ref security) = extensions.security { + if security.subject.is_some() { + println!(" [header-injector] WARNING: Subject visible (unexpected!)"); + } else { + println!(" [header-injector] Security subject: not visible (no read_subject)"); + } + } + + // COW copy to modify — tokens propagate from the executor + let mut modified = extensions.cow_copy(); + + // Add a label via MonotonicSet (has append_labels) + if modified.labels_write_token.is_some() { + modified.security.as_mut().unwrap().add_label("PROCESSED"); + println!(" [header-injector] Added label 'PROCESSED'"); + } + + // Inject a header via Guarded (has write_headers) + if let Some(ref token) = modified.http_write_token { + modified.http.as_mut().unwrap().write(token).set_header("X-Processed-By", "header-injector"); + println!(" [header-injector] Injected header 'X-Processed-By'"); + } + + PluginResult::modify_extensions(modified) + } +} + +// --------------------------------------------------------------------------- +// Plugin: AuditLogger +// Has read_headers, read_security, read_labels capabilities. +// Read-only — just logs what it can see. +// --------------------------------------------------------------------------- + +struct AuditLogger { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AuditLogger { + fn config(&self) -> &PluginConfig { &self.cfg } +} + +impl HookHandler for AuditLogger { + fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let is_result = payload.message.is_tool_result(); + let phase = if is_result { "POST" } else { "PRE" }; + + let tool_name = if is_result { + payload.message.get_tool_results() + .first() + .map(|tr| tr.tool_name.as_str()) + .unwrap_or("unknown") + } else { + payload.message.get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown") + }; + + print!(" [audit-logger] AUDIT[{}]: tool='{}' ", phase, tool_name); + + if let Some(ref security) = extensions.security { + let labels: Vec<&String> = security.labels.iter().collect(); + print!("labels={:?} ", labels); + } + + if let Some(ref http) = extensions.http { + if let Some(req_id) = http.get_header("X-Request-ID") { + print!("request_id='{}' ", req_id); + } + } + + if is_result { + let is_error = payload.message.get_tool_results() + .first() + .map(|tr| tr.is_error) + .unwrap_or(false); + print!("error={} ", is_error); + } + + println!(); + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Factories +// --------------------------------------------------------------------------- + +struct IdentityCheckerFactory; +impl PluginFactory for IdentityCheckerFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(IdentityChecker { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), + ("cmf.tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ], + }) + } +} + +struct HeaderInjectorFactory; +impl PluginFactory for HeaderInjectorFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(HeaderInjector { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ], + }) + } +} + +struct AuditLoggerFactory; +impl PluginFactory for AuditLoggerFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(AuditLogger { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), + ("cmf.tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ], + }) + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + println!("=== CMF Capabilities Demo ===\n"); + + // Load config from YAML file — capabilities declared per plugin + let config_path = "crates/cpex-core/examples/cmf_capabilities_demo.yaml"; + println!("--- Loading config from {} ---\n", config_path); + let yaml = std::fs::read_to_string(config_path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); + let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); + + let mut mgr = PluginManager::default(); + mgr.register_factory("builtin/identity-checker", Box::new(IdentityCheckerFactory)); + mgr.register_factory("builtin/header-injector", Box::new(HeaderInjectorFactory)); + mgr.register_factory("builtin/audit-logger", Box::new(AuditLoggerFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // --- Build CMF Message --- + let payload = MessagePayload { + message: Message { + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ + ContentPart::Text { text: "Looking up compensation.".into() }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_compensation".into(), + arguments: [("employee_id".to_string(), serde_json::json!(42))].into(), + namespace: None, + }, + }, + ], + channel: None, + }, + }; + + // --- Build Extensions with security, HTTP, meta --- + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR_DATA"); + security.classification = Some("confidential".into()); + security.subject = Some(cpex_core::extensions::security::SubjectExtension { + id: Some("alice".into()), + subject_type: Some(cpex_core::extensions::security::SubjectType::User), + roles: ["hr_admin".to_string()].into(), + permissions: ["read_compensation".to_string()].into(), + ..Default::default() + }); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer eyJ..."); + http.set_header("X-Request-ID", "req-abc-123"); + + let ext = Extensions { + request: Some(Arc::new(RequestExtension { + environment: Some("production".into()), + request_id: Some("req-abc-123".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + tags: ["pii".to_string(), "hr".to_string()].into(), + ..Default::default() + })), + ..Default::default() + }; + + // --- Pre-invoke: type-safe dispatch via invoke_named --- + println!("=== Phase 1: cmf.tool_pre_invoke ===\n"); + + // invoke_named gives compile-time payload type checking + // while routing to the specific "cmf.tool_pre_invoke" hook name + let (pre_result, bg) = mgr.invoke_named::( + "cmf.tool_pre_invoke", + payload, + ext, + None, // first hook — no context table + ).await; + + println!(); + if pre_result.continue_processing { + println!("Pre-invoke result: ALLOWED"); + if let Some(ref modified_ext) = pre_result.modified_extensions { + if let Some(ref sec) = modified_ext.security { + let labels: Vec<&String> = sec.labels.iter().collect(); + println!(" Labels after pre-invoke: {:?}", labels); + } + if let Some(ref http) = modified_ext.http { + println!(" Headers after pre-invoke: {:?}", http.request_headers); + } + } + } else { + println!("Pre-invoke result: DENIED — {}", pre_result.violation.as_ref().unwrap().reason); + bg.wait_for_background_tasks().await; + println!("\n=== Demo complete ==="); + return; + } + bg.wait_for_background_tasks().await; + + // --- Simulate tool execution --- + println!("\n--- Tool 'get_compensation' executes... ---"); + println!(" Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n"); + + // --- Post-invoke: different CMF message with tool result --- + println!("=== Phase 2: cmf.tool_post_invoke ===\n"); + + let post_payload = MessagePayload { + message: Message { + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), + role: Role::Tool, + content: vec![ + ContentPart::ToolResult { + content: cpex_core::cmf::ToolResult { + tool_call_id: "tc_001".into(), + tool_name: "get_compensation".into(), + content: serde_json::json!({"salary": 150000, "currency": "USD"}), + is_error: false, + }, + }, + ], + channel: None, + }, + }; + + // Build post-invoke extensions — carry forward any modifications + // from pre-invoke via the context table + let post_ext = pre_result.modified_extensions.unwrap_or_else(|| { + // Rebuild if no modifications + let mut security = SecurityExtension::default(); + security.add_label("PII"); + Extensions { + security: Some(Arc::new(security)), + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + } + }); + + // Thread the context table from pre-invoke to preserve plugin state + let (post_result, post_bg) = mgr.invoke_named::( + "cmf.tool_post_invoke", + post_payload, + post_ext, + Some(pre_result.context_table), + ).await; + + println!(); + if post_result.continue_processing { + println!("Post-invoke result: ALLOWED"); + } else { + println!("Post-invoke result: DENIED — {}", post_result.violation.as_ref().unwrap().reason); + } + + post_bg.wait_for_background_tasks().await; + println!("\n=== Demo complete ==="); +} diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.yaml b/crates/cpex-core/examples/cmf_capabilities_demo.yaml new file mode 100644 index 00000000..ace0f5cb --- /dev/null +++ b/crates/cpex-core/examples/cmf_capabilities_demo.yaml @@ -0,0 +1,50 @@ +# CMF Capabilities Demo Configuration +# +# Three plugins with different capabilities see different views +# of the same extensions. Demonstrates capability-gated access +# across pre-invoke and post-invoke hooks. + +plugin_settings: + routing_enabled: true + +global: + policies: + all: + plugins: [identity-checker, header-injector, audit-logger] + +plugins: + - name: identity-checker + kind: builtin/identity-checker + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: + - read_labels + - read_subject + - read_roles + + - name: header-injector + kind: builtin/header-injector + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + capabilities: + - read_headers + - write_headers + - append_labels + + - name: audit-logger + kind: builtin/audit-logger + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: audit + priority: 100 + on_error: ignore + capabilities: + - read_headers + - read_labels + +routes: + - tool: "*" + plugins: [] diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs index 8e5fb602..637eab88 100644 --- a/crates/cpex-core/examples/plugin_demo.rs +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -17,7 +17,7 @@ use cpex_core::error::{PluginError, PluginViolation}; use cpex_core::executor::PipelineResult; use cpex_core::factory::{PluginFactory, PluginInstance}; use cpex_core::hooks::adapter::TypedHandlerAdapter; -use cpex_core::hooks::payload::{Extensions, FilteredExtensions, MetaExtension}; +use cpex_core::hooks::payload::{Extensions, MetaExtension}; use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; use cpex_core::manager::PluginManager; use cpex_core::plugin::{Plugin, PluginConfig}; @@ -77,7 +77,7 @@ impl HookHandler for IdentityResolver { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { if payload.user.is_empty() { @@ -95,7 +95,7 @@ impl HookHandler for IdentityResolver { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { println!(" [identity-resolver] post-invoke: user '{}' completed '{}'", @@ -119,7 +119,7 @@ impl HookHandler for PiiGuard { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, ctx: &mut PluginContext, ) -> PluginResult { // Check if the user has PII clearance (simulated via context) @@ -156,7 +156,7 @@ impl HookHandler for AuditLogger { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { println!(" [audit-logger] LOG: user='{}' tool='{}' args='{}'", @@ -169,7 +169,7 @@ impl HookHandler for AuditLogger { fn handle( &self, payload: &ToolInvokePayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { println!(" [audit-logger] LOG: post-invoke user='{}' tool='{}'", @@ -229,12 +229,12 @@ impl PluginFactory for AuditLoggerFactory { fn make_tool_extensions(tool_name: &str, tags: &[&str]) -> Extensions { Extensions { - meta: Some(MetaExtension { + meta: Some(Arc::new(MetaExtension { entity_type: Some("tool".into()), entity_name: Some(tool_name.into()), tags: tags.iter().map(|s| s.to_string()).collect(), ..Default::default() - }), + })), ..Default::default() } } diff --git a/crates/cpex-core/src/cmf/constants.rs b/crates/cpex-core/src/cmf/constants.rs new file mode 100644 index 00000000..12a8ac5e --- /dev/null +++ b/crates/cpex-core/src/cmf/constants.rs @@ -0,0 +1,65 @@ +// Location: ./crates/cpex-core/src/cmf/constants.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF constants — schema version, serialization field names, and defaults. + +/// Current CMF message schema version. +pub const SCHEMA_VERSION: &str = "2.0"; + +// --------------------------------------------------------------------------- +// Serialization field names for MessageView::to_dict() / to_opa_input() +// --------------------------------------------------------------------------- + +// Core view fields +pub const FIELD_KIND: &str = "kind"; +pub const FIELD_ROLE: &str = "role"; +pub const FIELD_IS_PRE: &str = "is_pre"; +pub const FIELD_IS_POST: &str = "is_post"; +pub const FIELD_ACTION: &str = "action"; +pub const FIELD_HOOK: &str = "hook"; +pub const FIELD_URI: &str = "uri"; +pub const FIELD_NAME: &str = "name"; +pub const FIELD_CONTENT: &str = "content"; +pub const FIELD_SIZE_BYTES: &str = "size_bytes"; +pub const FIELD_MIME_TYPE: &str = "mime_type"; +pub const FIELD_ARGUMENTS: &str = "arguments"; + +// Extensions container +pub const FIELD_EXTENSIONS: &str = "extensions"; + +// Subject fields +pub const FIELD_SUBJECT: &str = "subject"; +pub const FIELD_ID: &str = "id"; +pub const FIELD_TYPE: &str = "type"; +pub const FIELD_ROLES: &str = "roles"; +pub const FIELD_PERMISSIONS: &str = "permissions"; +pub const FIELD_TEAMS: &str = "teams"; + +// Security fields +pub const FIELD_LABELS: &str = "labels"; + +// Request fields +pub const FIELD_ENVIRONMENT: &str = "environment"; + +// HTTP fields +pub const FIELD_HEADERS: &str = "headers"; + +// Agent fields +pub const FIELD_AGENT: &str = "agent"; +pub const FIELD_INPUT: &str = "input"; +pub const FIELD_SESSION_ID: &str = "session_id"; +pub const FIELD_CONVERSATION_ID: &str = "conversation_id"; +pub const FIELD_TURN: &str = "turn"; +pub const FIELD_AGENT_ID: &str = "agent_id"; +pub const FIELD_PARENT_AGENT_ID: &str = "parent_agent_id"; + +// Meta fields +pub const FIELD_META: &str = "meta"; +pub const FIELD_ENTITY_TYPE: &str = "entity_type"; +pub const FIELD_ENTITY_NAME: &str = "entity_name"; +pub const FIELD_TAGS: &str = "tags"; + +// OPA envelope +pub const FIELD_OPA_INPUT: &str = "input"; diff --git a/crates/cpex-core/src/cmf/content.rs b/crates/cpex-core/src/cmf/content.rs new file mode 100644 index 00000000..3cde9b65 --- /dev/null +++ b/crates/cpex-core/src/cmf/content.rs @@ -0,0 +1,486 @@ +// Location: ./crates/cpex-core/src/cmf/content.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF domain objects and ContentPart hierarchy. +// +// Domain objects (ToolCall, Resource, etc.) are standalone structs +// reusable outside of message content parts. ContentPart is a tagged +// enum that wraps them for message serialization. +// +// Mirrors the Python types in cpex/framework/cmf/message.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::enums::ResourceType; +use super::message::Message; + +// --------------------------------------------------------------------------- +// Domain Objects +// --------------------------------------------------------------------------- + +/// Normalized tool/function invocation request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + /// Unique request correlation ID. + pub tool_call_id: String, + /// Tool name. + pub name: String, + /// Arguments as a JSON-serializable map. + #[serde(default)] + pub arguments: HashMap, + /// Optional namespace for namespaced tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, +} + +/// Result from tool execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolResult { + /// Correlation ID linking to the corresponding tool call. + pub tool_call_id: String, + /// Name of the tool that was executed. + pub tool_name: String, + /// Result content (any JSON-serializable value). + #[serde(default)] + pub content: serde_json::Value, + /// Whether the result represents an error. + #[serde(default)] + pub is_error: bool, +} + +/// Embedded resource with content (MCP). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Resource { + /// Unique request correlation ID. + pub resource_request_id: String, + /// Unique identifier in URI format. + pub uri: String, + /// Human-readable name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// What this resource contains. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The kind of resource. + pub resource_type: ResourceType, + /// Text content if embedded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + /// Binary content if embedded (base64 in JSON). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blob: Option>, + /// MIME type of content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Size information. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + /// Metadata (classification, retention, etc.). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub annotations: HashMap, + /// Version tracking. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +impl Resource { + /// Whether content or blob is embedded. + pub fn is_embedded(&self) -> bool { + self.content.is_some() || self.blob.is_some() + } + + /// Get text content if available. + pub fn get_text_content(&self) -> Option<&str> { + self.content.as_deref() + } +} + +/// Lightweight resource reference without embedded content. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceReference { + /// Correlation ID linking to the originating resource request. + pub resource_request_id: String, + /// Resource URI. + pub uri: String, + /// Human-readable name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Type of resource. + pub resource_type: ResourceType, + /// Line number or byte offset for partial references. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range_start: Option, + /// End of range. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range_end: Option, + /// CSS/XPath/JSONPath selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, +} + +/// Prompt template invocation request (MCP). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptRequest { + /// Request ID for correlation. + pub prompt_request_id: String, + /// Prompt template name. + pub name: String, + /// Arguments to pass to the template. + #[serde(default)] + pub arguments: HashMap, + /// Source server for multi-server scenarios. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, +} + +/// Rendered prompt template result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptResult { + /// ID of the corresponding prompt request. + pub prompt_request_id: String, + /// Name of the prompt that was rendered. + pub prompt_name: String, + /// Rendered messages (prompts produce messages). + #[serde(default)] + pub messages: Vec, + /// Single text result for simple prompts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + /// Whether rendering failed. + #[serde(default)] + pub is_error: bool, + /// Error details if rendering failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_message: Option, +} + +// --------------------------------------------------------------------------- +// Media Source Types +// --------------------------------------------------------------------------- + +/// Image source data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageSource { + /// Source type: "url" or "base64". + #[serde(rename = "type")] + pub source_type: String, + /// URL or base64-encoded string. + pub data: String, + /// MIME type (e.g., image/jpeg). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, +} + +/// Video source data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoSource { + /// Source type: "url" or "base64". + #[serde(rename = "type")] + pub source_type: String, + /// URL or base64-encoded string. + pub data: String, + /// MIME type (e.g., video/mp4). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, + /// Duration in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, +} + +/// Audio source data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioSource { + /// Source type: "url" or "base64". + #[serde(rename = "type")] + pub source_type: String, + /// URL or base64-encoded string. + pub data: String, + /// MIME type (e.g., audio/mp3). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, + /// Duration in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, +} + +/// Document source data (PDF, Word, etc.). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentSource { + /// Source type: "url" or "base64". + #[serde(rename = "type")] + pub source_type: String, + /// URL or base64-encoded string. + pub data: String, + /// MIME type (e.g., application/pdf). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, + /// Document title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +// --------------------------------------------------------------------------- +// ContentPart — Tagged Enum +// --------------------------------------------------------------------------- + +/// A typed content part in a CMF message. +/// +/// Discriminated by the `content_type` field. Each variant wraps +/// either a text string or a domain object. +/// +/// Mirrors the Python `ContentPartUnion` discriminated union in +/// `cpex/framework/cmf/message.py`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "content_type")] +pub enum ContentPart { + /// Plain text content. + #[serde(rename = "text")] + Text { text: String }, + + /// Chain-of-thought reasoning. + #[serde(rename = "thinking")] + Thinking { text: String }, + + /// Tool/function invocation request. + #[serde(rename = "tool_call")] + ToolCall { content: ToolCall }, + + /// Result from tool execution. + #[serde(rename = "tool_result")] + ToolResult { content: ToolResult }, + + /// Embedded resource with content. + #[serde(rename = "resource")] + Resource { content: Resource }, + + /// Lightweight resource reference. + #[serde(rename = "resource_ref")] + ResourceRef { content: ResourceReference }, + + /// Prompt template invocation request. + #[serde(rename = "prompt_request")] + PromptRequest { content: PromptRequest }, + + /// Rendered prompt template result. + #[serde(rename = "prompt_result")] + PromptResult { content: PromptResult }, + + /// Image content. + #[serde(rename = "image")] + Image { content: ImageSource }, + + /// Video content. + #[serde(rename = "video")] + Video { content: VideoSource }, + + /// Audio content. + #[serde(rename = "audio")] + Audio { content: AudioSource }, + + /// Document content. + #[serde(rename = "document")] + Document { content: DocumentSource }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_text_content_part_serde() { + let json = r#"{"content_type":"text","text":"Hello, world!"}"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::Text { text } => assert_eq!(text, "Hello, world!"), + _ => panic!("expected Text variant"), + } + let roundtrip = serde_json::to_string(&part).unwrap(); + let part2: ContentPart = serde_json::from_str(&roundtrip).unwrap(); + match part2 { + ContentPart::Text { text } => assert_eq!(text, "Hello, world!"), + _ => panic!("expected Text variant"), + } + } + + #[test] + fn test_tool_call_content_part_serde() { + let json = r#"{ + "content_type": "tool_call", + "content": { + "tool_call_id": "tc_001", + "name": "get_weather", + "arguments": {"city": "London"} + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::ToolCall { content } => { + assert_eq!(content.name, "get_weather"); + assert_eq!(content.tool_call_id, "tc_001"); + assert_eq!(content.arguments["city"], "London"); + } + _ => panic!("expected ToolCall variant"), + } + } + + #[test] + fn test_tool_result_content_part_serde() { + let json = r#"{ + "content_type": "tool_result", + "content": { + "tool_call_id": "tc_001", + "tool_name": "get_weather", + "content": {"temp": 20, "unit": "C"}, + "is_error": false + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::ToolResult { content } => { + assert_eq!(content.tool_name, "get_weather"); + assert!(!content.is_error); + } + _ => panic!("expected ToolResult variant"), + } + } + + #[test] + fn test_resource_content_part_serde() { + let json = r#"{ + "content_type": "resource", + "content": { + "resource_request_id": "rr_001", + "uri": "file:///data.txt", + "resource_type": "file", + "content": "Hello from file" + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::Resource { content } => { + assert_eq!(content.uri, "file:///data.txt"); + assert!(content.is_embedded()); + assert_eq!(content.get_text_content(), Some("Hello from file")); + } + _ => panic!("expected Resource variant"), + } + } + + #[test] + fn test_resource_ref_content_part_serde() { + let json = r#"{ + "content_type": "resource_ref", + "content": { + "resource_request_id": "rr_002", + "uri": "db://users/42", + "resource_type": "database" + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::ResourceRef { content } => { + assert_eq!(content.uri, "db://users/42"); + assert_eq!(content.resource_type, ResourceType::Database); + } + _ => panic!("expected ResourceRef variant"), + } + } + + #[test] + fn test_image_content_part_serde() { + let json = r#"{ + "content_type": "image", + "content": { + "type": "url", + "data": "https://example.com/photo.jpg", + "media_type": "image/jpeg" + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::Image { content } => { + assert_eq!(content.source_type, "url"); + assert_eq!(content.data, "https://example.com/photo.jpg"); + } + _ => panic!("expected Image variant"), + } + } + + #[test] + fn test_prompt_request_content_part_serde() { + let json = r#"{ + "content_type": "prompt_request", + "content": { + "prompt_request_id": "pr_001", + "name": "summarize", + "arguments": {"text": "Long document..."} + } + }"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::PromptRequest { content } => { + assert_eq!(content.name, "summarize"); + } + _ => panic!("expected PromptRequest variant"), + } + } + + #[test] + fn test_thinking_content_part_serde() { + let json = r#"{"content_type":"thinking","text":"Let me analyze..."}"#; + let part: ContentPart = serde_json::from_str(json).unwrap(); + match &part { + ContentPart::Thinking { text } => assert_eq!(text, "Let me analyze..."), + _ => panic!("expected Thinking variant"), + } + } + + #[test] + fn test_tool_call_construction() { + let tc = ToolCall { + tool_call_id: "tc_001".into(), + name: "search".into(), + arguments: [("query".to_string(), serde_json::json!("rust"))].into(), + namespace: None, + }; + assert_eq!(tc.name, "search"); + assert_eq!(tc.arguments["query"], "rust"); + } + + #[test] + fn test_resource_is_embedded() { + let embedded = Resource { + resource_request_id: "rr_001".into(), + uri: "file:///data.txt".into(), + name: None, + description: None, + resource_type: ResourceType::File, + content: Some("data".into()), + blob: None, + mime_type: None, + size_bytes: None, + annotations: HashMap::new(), + version: None, + }; + assert!(embedded.is_embedded()); + + let not_embedded = Resource { + resource_request_id: "rr_002".into(), + uri: "file:///other.txt".into(), + name: None, + description: None, + resource_type: ResourceType::File, + content: None, + blob: None, + mime_type: None, + size_bytes: None, + annotations: HashMap::new(), + version: None, + }; + assert!(!not_embedded.is_embedded()); + } +} diff --git a/crates/cpex-core/src/cmf/enums.rs b/crates/cpex-core/src/cmf/enums.rs new file mode 100644 index 00000000..97f1dcc2 --- /dev/null +++ b/crates/cpex-core/src/cmf/enums.rs @@ -0,0 +1,176 @@ +// Location: ./crates/cpex-core/src/cmf/enums.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF enums — Role, Channel, ContentType, ResourceType. +// +// Mirrors the Python enums in cpex/framework/cmf/message.py. +// All use snake_case serialization to match Python string values. + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Role +// --------------------------------------------------------------------------- + +/// Identifies WHO is speaking in a conversation turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Role { + /// System-level instructions. + System, + /// Developer-provided instructions. + Developer, + /// Human user input. + User, + /// LLM/agent response. + Assistant, + /// Tool execution result. + Tool, +} + +// --------------------------------------------------------------------------- +// Channel +// --------------------------------------------------------------------------- + +/// Classifies the kind of output a message represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Channel { + /// Intermediate analytical output (chain-of-thought). + Analysis, + /// Meta-level observations about the task. + Commentary, + /// Terminal response intended for delivery. + Final, +} + +// --------------------------------------------------------------------------- +// ContentType +// --------------------------------------------------------------------------- + +/// Discriminator for the typed ContentPart hierarchy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentType { + /// Plain text content. + Text, + /// Chain-of-thought reasoning. + Thinking, + /// Tool/function invocation request. + ToolCall, + /// Result from tool execution. + ToolResult, + /// Embedded resource with content (MCP). + Resource, + /// Lightweight resource reference without embedded content. + ResourceRef, + /// Prompt template invocation request (MCP). + PromptRequest, + /// Rendered prompt template result. + PromptResult, + /// Image content (URL or base64). + Image, + /// Video content (URL or base64). + Video, + /// Audio content (URL or base64). + Audio, + /// Document content (PDF, Word, etc.). + Document, +} + +// --------------------------------------------------------------------------- +// ResourceType +// --------------------------------------------------------------------------- + +/// Type of resource being referenced. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResourceType { + /// File-system resource. + #[default] + File, + /// Binary large object. + Blob, + /// Generic URI-addressable resource. + Uri, + /// Database entity. + Database, + /// API endpoint. + Api, + /// In-memory or ephemeral resource. + Memory, + /// Produced artifact (generated output, build result). + Artifact, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_role_serde_roundtrip() { + let role = Role::Assistant; + let json = serde_json::to_string(&role).unwrap(); + assert_eq!(json, "\"assistant\""); + let deserialized: Role = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, Role::Assistant); + } + + #[test] + fn test_channel_serde_roundtrip() { + let channel = Channel::Final; + let json = serde_json::to_string(&channel).unwrap(); + assert_eq!(json, "\"final\""); + let deserialized: Channel = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, Channel::Final); + } + + #[test] + fn test_content_type_serde_roundtrip() { + let ct = ContentType::ToolCall; + let json = serde_json::to_string(&ct).unwrap(); + assert_eq!(json, "\"tool_call\""); + let deserialized: ContentType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, ContentType::ToolCall); + } + + #[test] + fn test_content_type_resource_ref() { + let ct = ContentType::ResourceRef; + let json = serde_json::to_string(&ct).unwrap(); + assert_eq!(json, "\"resource_ref\""); + } + + #[test] + fn test_content_type_prompt_variants() { + let req = ContentType::PromptRequest; + let res = ContentType::PromptResult; + assert_eq!(serde_json::to_string(&req).unwrap(), "\"prompt_request\""); + assert_eq!(serde_json::to_string(&res).unwrap(), "\"prompt_result\""); + } + + #[test] + fn test_resource_type_serde_roundtrip() { + let rt = ResourceType::Database; + let json = serde_json::to_string(&rt).unwrap(); + assert_eq!(json, "\"database\""); + let deserialized: ResourceType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, ResourceType::Database); + } + + #[test] + fn test_all_roles_deserialize() { + for (s, expected) in &[ + ("\"system\"", Role::System), + ("\"developer\"", Role::Developer), + ("\"user\"", Role::User), + ("\"assistant\"", Role::Assistant), + ("\"tool\"", Role::Tool), + ] { + let role: Role = serde_json::from_str(s).unwrap(); + assert_eq!(role, *expected); + } + } +} diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs new file mode 100644 index 00000000..a8e700d5 --- /dev/null +++ b/crates/cpex-core/src/cmf/message.rs @@ -0,0 +1,462 @@ +// Location: ./crates/cpex-core/src/cmf/message.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF Message — canonical message representation. +// +// A Message is the storage and wire format for a single turn in a +// conversation. It preserves structure exactly as the LLM or +// framework sent it. +// +// Extensions are NOT part of the Message. They are passed separately +// to handlers via the framework's Extensions type. This allows +// extensions to be shared across payload types and avoids copying +// the message when extensions change. +// +// Mirrors the Python Message in cpex/framework/cmf/message.py. + +use serde::{Deserialize, Serialize}; + +use super::content::*; +use super::enums::{Channel, Role}; +use crate::hooks::trait_def::PluginResult; + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +/// Canonical CMF message representing a single turn in a conversation. +/// +/// All content is carried as typed ContentPart variants. Extensions +/// (identity, security, HTTP, agent context) are passed separately +/// to handlers — not inside the message. +/// +/// Mirrors the Python `Message` in `cpex/framework/cmf/message.py`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + /// Message schema version. + #[serde(default = "default_schema_version")] + pub schema_version: String, + + /// Who is speaking. + pub role: Role, + + /// List of typed content parts (multimodal). + #[serde(default)] + pub content: Vec, + + /// Optional output classification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, +} + +fn default_schema_version() -> String { + super::constants::SCHEMA_VERSION.to_string() +} + +impl Message { + /// Create a simple text message. + pub fn text(role: Role, text: impl Into) -> Self { + Self { + schema_version: super::constants::SCHEMA_VERSION.to_string(), + role, + content: vec![ContentPart::Text { + text: text.into(), + }], + channel: None, + } + } + + /// Extract all text content from the message. + /// + /// Concatenates text from all `Text` content parts. + pub fn get_text_content(&self) -> String { + let mut texts = Vec::new(); + for part in &self.content { + if let ContentPart::Text { text } = part { + texts.push(text.as_str()); + } + } + texts.join("") + } + + /// Extract thinking/reasoning content if present. + pub fn get_thinking_content(&self) -> Option { + let mut texts = Vec::new(); + for part in &self.content { + if let ContentPart::Thinking { text } = part { + texts.push(text.as_str()); + } + } + if texts.is_empty() { + None + } else { + Some(texts.join("")) + } + } + + /// Get all tool calls in this message. + pub fn get_tool_calls(&self) -> Vec<&ToolCall> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::ToolCall { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Get all tool results in this message. + pub fn get_tool_results(&self) -> Vec<&ToolResult> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::ToolResult { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Whether this message contains any tool calls. + pub fn is_tool_call(&self) -> bool { + self.content + .iter() + .any(|p| matches!(p, ContentPart::ToolCall { .. })) + } + + /// Whether this message contains any tool results. + pub fn is_tool_result(&self) -> bool { + self.content + .iter() + .any(|p| matches!(p, ContentPart::ToolResult { .. })) + } + + /// Get all embedded resources in this message. + pub fn get_resources(&self) -> Vec<&Resource> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::Resource { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Get all resource references in this message. + pub fn get_resource_refs(&self) -> Vec<&ResourceReference> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::ResourceRef { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Get all resource URIs (both embedded and references). + pub fn get_all_resource_uris(&self) -> Vec<&str> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::Resource { content } => Some(content.uri.as_str()), + ContentPart::ResourceRef { content } => Some(content.uri.as_str()), + _ => None, + }) + .collect() + } + + /// Whether this message contains any resources or resource references. + pub fn has_resources(&self) -> bool { + self.content.iter().any(|p| { + matches!( + p, + ContentPart::Resource { .. } | ContentPart::ResourceRef { .. } + ) + }) + } + + /// Get all prompt requests in this message. + pub fn get_prompt_requests(&self) -> Vec<&PromptRequest> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::PromptRequest { content } => Some(content), + _ => None, + }) + .collect() + } + + /// Get all prompt results in this message. + pub fn get_prompt_results(&self) -> Vec<&PromptResult> { + self.content + .iter() + .filter_map(|part| match part { + ContentPart::PromptResult { content } => Some(content), + _ => None, + }) + .collect() + } +} + +// --------------------------------------------------------------------------- +// MessagePayload — PluginPayload wrapper +// --------------------------------------------------------------------------- + +/// CMF Message wrapped as a PluginPayload for hook dispatch. +/// +/// This is the payload type for all `cmf.*` hooks. Plugins that +/// handle CMF hooks implement `HookHandler` and receive +/// `&MessagePayload` in their handler. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessagePayload { + /// The CMF message. + pub message: Message, +} + +crate::impl_plugin_payload!(MessagePayload); + +// --------------------------------------------------------------------------- +// CmfHook — Hook Type Definition +// --------------------------------------------------------------------------- + +crate::define_hook! { + /// CMF message evaluation hook. + /// + /// Plugins implement `HookHandler` and register under + /// one or more `cmf.*` hook names (e.g., `cmf.tool_pre_invoke`, + /// `cmf.llm_input`). The same handler covers all CMF hook points. + CmfHook, "cmf" => { + payload: MessagePayload, + result: PluginResult, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::payload::PluginPayload; + use crate::hooks::trait_def::HookTypeDef; + + #[test] + fn test_message_text_helper() { + let msg = Message::text(Role::User, "What is the weather?"); + assert_eq!(msg.get_text_content(), "What is the weather?"); + assert_eq!(msg.role, Role::User); + assert_eq!(msg.schema_version, "2.0"); + } + + #[test] + fn test_message_multi_part_text() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Text { + text: "Hello ".into(), + }, + ContentPart::Text { + text: "world!".into(), + }, + ], + channel: None, + }; + assert_eq!(msg.get_text_content(), "Hello world!"); + } + + #[test] + fn test_message_thinking_content() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Thinking { + text: "Let me think...".into(), + }, + ContentPart::Text { + text: "Here's my answer.".into(), + }, + ], + channel: Some(Channel::Final), + }; + assert_eq!( + msg.get_thinking_content(), + Some("Let me think...".to_string()) + ); + assert_eq!(msg.get_text_content(), "Here's my answer."); + } + + #[test] + fn test_message_tool_calls() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Text { + text: "Let me check.".into(), + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_weather".into(), + arguments: [("city".to_string(), serde_json::json!("London"))].into(), + namespace: None, + }, + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_002".into(), + name: "get_time".into(), + arguments: [("timezone".to_string(), serde_json::json!("UTC"))].into(), + namespace: None, + }, + }, + ], + channel: None, + }; + assert!(msg.is_tool_call()); + assert!(!msg.is_tool_result()); + let calls = msg.get_tool_calls(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "get_weather"); + assert_eq!(calls[1].name, "get_time"); + } + + #[test] + fn test_message_tool_results() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Tool, + content: vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: "tc_001".into(), + tool_name: "get_weather".into(), + content: serde_json::json!({"temp": 20}), + is_error: false, + }, + }], + channel: None, + }; + assert!(msg.is_tool_result()); + assert!(!msg.is_tool_call()); + let results = msg.get_tool_results(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_name, "get_weather"); + } + + #[test] + fn test_message_resources() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Resource { + content: Resource { + resource_request_id: "rr_001".into(), + uri: "file:///data.txt".into(), + name: Some("Data File".into()), + description: None, + resource_type: super::super::enums::ResourceType::File, + content: Some("file contents".into()), + blob: None, + mime_type: None, + size_bytes: None, + annotations: std::collections::HashMap::new(), + version: None, + }, + }, + ContentPart::ResourceRef { + content: ResourceReference { + resource_request_id: "rr_002".into(), + uri: "db://users/42".into(), + name: None, + resource_type: super::super::enums::ResourceType::Database, + range_start: None, + range_end: None, + selector: None, + }, + }, + ], + channel: None, + }; + assert!(msg.has_resources()); + assert_eq!(msg.get_resources().len(), 1); + assert_eq!(msg.get_resource_refs().len(), 1); + let uris = msg.get_all_resource_uris(); + assert_eq!(uris.len(), 2); + assert!(uris.contains(&"file:///data.txt")); + assert!(uris.contains(&"db://users/42")); + } + + #[test] + fn test_message_no_resources() { + let msg = Message::text(Role::User, "Hello"); + assert!(!msg.has_resources()); + assert!(msg.get_resources().is_empty()); + } + + #[test] + fn test_message_serde_roundtrip() { + let msg = Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Thinking { + text: "Analyzing...".into(), + }, + ContentPart::Text { + text: "Here's the answer.".into(), + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "search".into(), + arguments: [("q".to_string(), serde_json::json!("rust"))].into(), + namespace: None, + }, + }, + ], + channel: Some(Channel::Final), + }; + + let json = serde_json::to_string(&msg).unwrap(); + let deserialized: Message = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.role, Role::Assistant); + assert_eq!(deserialized.schema_version, "2.0"); + assert_eq!(deserialized.channel, Some(Channel::Final)); + assert_eq!(deserialized.content.len(), 3); + assert_eq!(deserialized.get_text_content(), "Here's the answer."); + assert_eq!(deserialized.get_tool_calls().len(), 1); + } + + #[test] + fn test_message_payload_as_plugin_payload() { + let payload = MessagePayload { + message: Message::text(Role::User, "Hello"), + }; + + // Test clone_boxed + let boxed: Box = Box::new(payload.clone()); + let cloned = boxed.clone_boxed(); + + // Test as_any downcast + let downcasted = cloned + .as_any() + .downcast_ref::() + .expect("should downcast to MessagePayload"); + assert_eq!(downcasted.message.get_text_content(), "Hello"); + } + + #[test] + fn test_cmf_hook_type_def() { + assert_eq!(CmfHook::NAME, "cmf"); + } + + #[test] + fn test_message_default_schema_version() { + let json = r#"{"role":"user","content":[]}"#; + let msg: Message = serde_json::from_str(json).unwrap(); + assert_eq!(msg.schema_version, "2.0"); + } +} diff --git a/crates/cpex-core/src/cmf/mod.rs b/crates/cpex-core/src/cmf/mod.rs new file mode 100644 index 00000000..11ae76a4 --- /dev/null +++ b/crates/cpex-core/src/cmf/mod.rs @@ -0,0 +1,100 @@ +// Location: ./crates/cpex-core/src/cmf/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// ContextForge Message Format (CMF). +// +// Canonical message representation for interactions between users, +// agents, tools, and language models. All models mirror the Python +// CMF in cpex/framework/cmf/message.py. +// +// Extensions are NOT part of the Message — they are passed separately +// to handlers via the framework's Extensions type in hooks/payload.rs. +// This allows extensions to be shared across payload types and avoids +// copying the message when extensions change. +// +// # Hook Registration Patterns +// +// CMF supports two registration patterns for plugins: +// +// ## Pattern 1: One handler, multiple hook names (recommended) +// +// Use `CmfHook` as the hook type and register under multiple names. +// The plugin writes one handler that covers all CMF hooks. The host +// invokes via `invoke_by_name("cmf.tool_pre_invoke", ...)`. +// +// ```rust,ignore +// // Plugin implements one handler: +// impl HookHandler for MyPlugin { +// fn handle(&self, payload: &MessagePayload, ext: &Extensions, ctx: &mut PluginContext) +// -> PluginResult { ... } +// } +// +// // Factory registers under multiple names: +// PluginInstance { +// plugin: plugin.clone(), +// handlers: vec![ +// ("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), +// ("cmf.tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), +// ], +// } +// +// // Host invokes via invoke_named — compile-time payload type safety +// // plus runtime hook name routing: +// mgr.invoke_named::( +// "cmf.tool_pre_invoke", payload, ext, None, +// ).await; +// ``` +// +// `invoke_named::(hook_name, ...)` gives you both: +// - **Compile-time**: payload must be `MessagePayload` (from `CmfHook::Payload`) +// - **Runtime**: dispatches to plugins registered under the specific hook name +// +// This is the recommended approach for CMF hooks. Alternatively, use +// `invoke_by_name(hook_name, boxed_payload, ...)` for fully dynamic +// dispatch (no compile-time payload check). +// +// ## Pattern 2: Individual hook types (optional) +// +// For hosts that want per-hook marker types, define separate hook +// types. Each maps to one hook name. The plugin must implement a +// handler per type (more boilerplate). +// +// ```rust,ignore +// define_hook! { +// CmfToolPreInvoke, "cmf.tool_pre_invoke" => { +// payload: MessagePayload, +// result: PluginResult, +// } +// } +// +// // Plugin implements per-hook handlers: +// impl HookHandler for MyPlugin { ... } +// impl HookHandler for MyPlugin { ... } +// +// // Host uses typed invoke: +// mgr.invoke::(payload, ext, None).await; +// ``` +// +// Both patterns use the same executor, registry, and capabilities. +// Pattern 1 with `invoke_named` is recommended — one handler impl, +// compile-time payload safety, and explicit hook name routing. +// +// Available CMF hook names (defined in hooks/types.rs): +// cmf.tool_pre_invoke, cmf.tool_post_invoke, +// cmf.llm_input, cmf.llm_output, +// cmf.prompt_pre_fetch, cmf.prompt_post_fetch, +// cmf.resource_pre_fetch, cmf.resource_post_fetch + +pub mod constants; +pub mod content; +pub mod enums; +pub mod message; +pub mod view; + +// Re-export key types at the cmf module level +pub use content::*; +pub use enums::*; +pub use message::{CmfHook, Message, MessagePayload}; +pub use view::{MessageView, ViewAction, ViewKind}; diff --git a/crates/cpex-core/src/cmf/view.rs b/crates/cpex-core/src/cmf/view.rs new file mode 100644 index 00000000..3407b472 --- /dev/null +++ b/crates/cpex-core/src/cmf/view.rs @@ -0,0 +1,848 @@ +// Location: ./crates/cpex-core/src/cmf/view.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MessageView — read-only projection for policy evaluation. +// +// Decomposes a Message into individually addressable views with a +// uniform interface regardless of content type. Zero-copy design — +// properties are computed on-demand by borrowing the underlying +// content part and extensions directly. +// +// Mirrors the Python MessageView in cpex/framework/cmf/view.py. + +use serde::{Deserialize, Serialize}; + +use super::content::*; +use super::enums::{ContentType, Role}; +use super::message::Message; +use crate::hooks::payload::Extensions; + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +/// Type of content a view represents. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ViewKind { + Text, + Thinking, + ToolCall, + ToolResult, + Resource, + ResourceRef, + PromptRequest, + PromptResult, + Image, + Video, + Audio, + Document, +} + +/// The action this content represents in the data flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ViewAction { + Read, + Write, + Execute, + Invoke, + Send, + Receive, + Generate, +} + +impl ViewKind { + /// Map ContentType to ViewKind. + pub fn from_content_type(ct: ContentType) -> Self { + match ct { + ContentType::Text => ViewKind::Text, + ContentType::Thinking => ViewKind::Thinking, + ContentType::ToolCall => ViewKind::ToolCall, + ContentType::ToolResult => ViewKind::ToolResult, + ContentType::Resource => ViewKind::Resource, + ContentType::ResourceRef => ViewKind::ResourceRef, + ContentType::PromptRequest => ViewKind::PromptRequest, + ContentType::PromptResult => ViewKind::PromptResult, + ContentType::Image => ViewKind::Image, + ContentType::Video => ViewKind::Video, + ContentType::Audio => ViewKind::Audio, + ContentType::Document => ViewKind::Document, + } + } + + /// The default action for this kind of content. + pub fn default_action(&self, role: Role) -> ViewAction { + match self { + ViewKind::ToolCall => ViewAction::Execute, + ViewKind::ToolResult => ViewAction::Receive, + ViewKind::Resource | ViewKind::ResourceRef => ViewAction::Read, + ViewKind::PromptRequest => ViewAction::Invoke, + ViewKind::PromptResult => ViewAction::Receive, + // Direction-dependent kinds + ViewKind::Text | ViewKind::Thinking | ViewKind::Image + | ViewKind::Video | ViewKind::Audio | ViewKind::Document => { + match role { + Role::User => ViewAction::Send, + Role::Assistant => ViewAction::Generate, + Role::Tool => ViewAction::Receive, + Role::System | Role::Developer => ViewAction::Write, + } + } + } + } + + /// Whether this is a tool-related kind. + pub fn is_tool(&self) -> bool { + matches!(self, ViewKind::ToolCall | ViewKind::ToolResult) + } + + /// Whether this is a resource-related kind. + pub fn is_resource(&self) -> bool { + matches!(self, ViewKind::Resource | ViewKind::ResourceRef) + } + + /// Whether this is a prompt-related kind. + pub fn is_prompt(&self) -> bool { + matches!(self, ViewKind::PromptRequest | ViewKind::PromptResult) + } + + /// Whether this is a media kind (image, video, audio, document). + pub fn is_media(&self) -> bool { + matches!( + self, + ViewKind::Image | ViewKind::Video | ViewKind::Audio | ViewKind::Document + ) + } + + /// Whether this is a text kind (text or thinking). + pub fn is_text(&self) -> bool { + matches!(self, ViewKind::Text | ViewKind::Thinking) + } +} + +// --------------------------------------------------------------------------- +// MessageView +// --------------------------------------------------------------------------- + +/// Read-only, zero-copy view over a single content part. +/// +/// Provides a uniform interface for policy evaluation regardless +/// of content type. Properties are computed on-demand by borrowing +/// the underlying content part and extensions. +/// +/// Produced by `Message::iter_views()` or the standalone `iter_views()`. +pub struct MessageView<'a> { + /// The underlying content part. + part: &'a ContentPart, + /// The kind of content. + kind: ViewKind, + /// The parent message role. + role: Role, + /// Optional hook location (e.g., "tool_pre_invoke"). + hook: Option<&'a str>, + /// Optional extensions (for security/http context). + extensions: Option<&'a Extensions>, +} + +impl<'a> MessageView<'a> { + /// Create a new view over a content part. + pub fn new( + part: &'a ContentPart, + role: Role, + hook: Option<&'a str>, + extensions: Option<&'a Extensions>, + ) -> Self { + let kind = match part { + ContentPart::Text { .. } => ViewKind::Text, + ContentPart::Thinking { .. } => ViewKind::Thinking, + ContentPart::ToolCall { .. } => ViewKind::ToolCall, + ContentPart::ToolResult { .. } => ViewKind::ToolResult, + ContentPart::Resource { .. } => ViewKind::Resource, + ContentPart::ResourceRef { .. } => ViewKind::ResourceRef, + ContentPart::PromptRequest { .. } => ViewKind::PromptRequest, + ContentPart::PromptResult { .. } => ViewKind::PromptResult, + ContentPart::Image { .. } => ViewKind::Image, + ContentPart::Video { .. } => ViewKind::Video, + ContentPart::Audio { .. } => ViewKind::Audio, + ContentPart::Document { .. } => ViewKind::Document, + }; + + Self { + part, + kind, + role, + hook, + extensions, + } + } + + // -- Core properties -- + + /// The kind of content this view represents. + pub fn kind(&self) -> ViewKind { + self.kind + } + + /// The role of the parent message. + pub fn role(&self) -> Role { + self.role + } + + /// The underlying content part. + pub fn raw(&self) -> &'a ContentPart { + self.part + } + + /// The hook location, if set. + pub fn hook(&self) -> Option<&str> { + self.hook + } + + /// The action this content represents. + pub fn action(&self) -> ViewAction { + self.kind.default_action(self.role) + } + + // -- Phase helpers -- + + /// Whether this is a pre-execution hook (tool_pre_invoke, prompt_pre_fetch, etc.). + pub fn is_pre(&self) -> bool { + self.hook.map_or(false, |h| h.contains("pre")) + } + + /// Whether this is a post-execution hook. + pub fn is_post(&self) -> bool { + self.hook.map_or(false, |h| h.contains("post")) + } + + // -- Universal properties -- + + /// Text content (for text, thinking, tool result content). + pub fn content(&self) -> Option<&str> { + match self.part { + ContentPart::Text { text } | ContentPart::Thinking { text } => Some(text), + ContentPart::ToolResult { content: tr } => { + tr.content.as_str().map(|s| Some(s)).unwrap_or(None) + } + ContentPart::Resource { content: r } => r.content.as_deref(), + ContentPart::PromptResult { content: pr } => pr.content.as_deref(), + _ => None, + } + } + + /// Entity name (tool name, resource URI, prompt name). + pub fn name(&self) -> Option<&str> { + match self.part { + ContentPart::ToolCall { content: tc } => Some(&tc.name), + ContentPart::ToolResult { content: tr } => Some(&tr.tool_name), + ContentPart::Resource { content: r } => r.name.as_deref().or(Some(&r.uri)), + ContentPart::ResourceRef { content: rr } => rr.name.as_deref().or(Some(&rr.uri)), + ContentPart::PromptRequest { content: pr } => Some(&pr.name), + ContentPart::PromptResult { content: pr } => Some(&pr.prompt_name), + _ => None, + } + } + + /// URI for the entity. + pub fn uri(&self) -> Option { + match self.part { + ContentPart::ToolCall { content: tc } => { + Some(format!("tool://_/{}", tc.name)) + } + ContentPart::Resource { content: r } => Some(r.uri.clone()), + ContentPart::ResourceRef { content: rr } => Some(rr.uri.clone()), + ContentPart::PromptRequest { content: pr } => { + Some(format!("prompt://_/{}", pr.name)) + } + _ => None, + } + } + + /// Arguments (for tool calls and prompt requests). + pub fn args(&self) -> Option<&std::collections::HashMap> { + match self.part { + ContentPart::ToolCall { content: tc } => Some(&tc.arguments), + ContentPart::PromptRequest { content: pr } => Some(&pr.arguments), + _ => None, + } + } + + /// Get a specific argument by name. + pub fn get_arg(&self, name: &str) -> Option<&serde_json::Value> { + self.args().and_then(|a| a.get(name)) + } + + /// Whether this content has arguments. + pub fn has_arg(&self, name: &str) -> bool { + self.get_arg(name).is_some() + } + + /// MIME type (for resources, media). + pub fn mime_type(&self) -> Option<&str> { + match self.part { + ContentPart::Resource { content: r } => r.mime_type.as_deref(), + ContentPart::Image { content: img } => img.media_type.as_deref(), + ContentPart::Video { content: vid } => vid.media_type.as_deref(), + ContentPart::Audio { content: aud } => aud.media_type.as_deref(), + ContentPart::Document { content: doc } => doc.media_type.as_deref(), + _ => None, + } + } + + /// Whether the result is an error (tool results, prompt results). + pub fn is_error(&self) -> bool { + match self.part { + ContentPart::ToolResult { content: tr } => tr.is_error, + ContentPart::PromptResult { content: pr } => pr.is_error, + _ => false, + } + } + + // -- Type helpers -- + + pub fn is_tool(&self) -> bool { self.kind.is_tool() } + pub fn is_resource(&self) -> bool { self.kind.is_resource() } + pub fn is_prompt(&self) -> bool { self.kind.is_prompt() } + pub fn is_media(&self) -> bool { self.kind.is_media() } + pub fn is_text(&self) -> bool { self.kind.is_text() } + + // -- Extension accessors -- + + /// Get the extensions, if provided. + pub fn extensions(&self) -> Option<&'a Extensions> { + self.extensions + } + + /// Check if a security label exists. + pub fn has_label(&self, label: &str) -> bool { + self.extensions + .and_then(|e| e.security.as_ref()) + .map(|s| s.has_label(label)) + .unwrap_or(false) + } + + /// Get an HTTP header value. + pub fn get_header(&self, name: &str) -> Option<&str> { + self.extensions + .and_then(|e| e.http.as_ref()) + .and_then(|h| h.get_header(name)) + } + + // -- Serialization -- + + /// Sensitive headers stripped during serialization. + const SENSITIVE_HEADERS: &'static [&'static str] = &["authorization", "cookie", "x-api-key"]; + + /// Serialize the view to a JSON-compatible map. + /// + /// Includes the view's properties, arguments, and optionally + /// text content and extension context. Sensitive headers + /// (Authorization, Cookie, X-API-Key) are stripped. + pub fn to_dict( + &self, + include_content: bool, + include_context: bool, + ) -> serde_json::Value { + use super::constants::*; + + let mut result = serde_json::Map::new(); + + // Core fields + result.insert(FIELD_KIND.into(), serde_json::json!(self.kind)); + result.insert(FIELD_ROLE.into(), serde_json::json!(self.role)); + result.insert(FIELD_IS_PRE.into(), serde_json::json!(self.is_pre())); + result.insert(FIELD_IS_POST.into(), serde_json::json!(self.is_post())); + result.insert(FIELD_ACTION.into(), serde_json::json!(self.action())); + + if let Some(hook) = self.hook { + result.insert(FIELD_HOOK.into(), serde_json::json!(hook)); + } + + if let Some(uri) = self.uri() { + result.insert(FIELD_URI.into(), serde_json::json!(uri)); + } + + if let Some(name) = self.name() { + result.insert(FIELD_NAME.into(), serde_json::json!(name)); + } + + // Content + if include_content { + if let Some(text) = self.content() { + result.insert(FIELD_SIZE_BYTES.into(), serde_json::json!(text.len())); + result.insert(FIELD_CONTENT.into(), serde_json::json!(text)); + } + } + + if let Some(mime) = self.mime_type() { + result.insert(FIELD_MIME_TYPE.into(), serde_json::json!(mime)); + } + + // Arguments + if let Some(args) = self.args() { + result.insert(FIELD_ARGUMENTS.into(), serde_json::json!(args)); + } + + // Extensions context + if include_context { + if let Some(ext) = self.extensions { + let mut ext_map = serde_json::Map::new(); + + // Subject + if let Some(ref sec) = ext.security { + if let Some(ref subject) = sec.subject { + let mut sub_map = serde_json::Map::new(); + if let Some(ref id) = subject.id { + sub_map.insert(FIELD_ID.into(), serde_json::json!(id)); + } + if let Some(ref st) = subject.subject_type { + sub_map.insert(FIELD_TYPE.into(), serde_json::json!(st)); + } + if !subject.roles.is_empty() { + let mut roles: Vec<&String> = subject.roles.iter().collect(); + roles.sort(); + sub_map.insert(FIELD_ROLES.into(), serde_json::json!(roles)); + } + if !subject.permissions.is_empty() { + let mut perms: Vec<&String> = subject.permissions.iter().collect(); + perms.sort(); + sub_map.insert(FIELD_PERMISSIONS.into(), serde_json::json!(perms)); + } + if !subject.teams.is_empty() { + let mut teams: Vec<&String> = subject.teams.iter().collect(); + teams.sort(); + sub_map.insert(FIELD_TEAMS.into(), serde_json::json!(teams)); + } + if !sub_map.is_empty() { + ext_map.insert(FIELD_SUBJECT.into(), serde_json::Value::Object(sub_map)); + } + } + + // Labels + if !sec.labels.is_empty() { + let mut labels: Vec<&String> = sec.labels.iter().collect(); + labels.sort(); + ext_map.insert(FIELD_LABELS.into(), serde_json::json!(labels)); + } + } + + // Environment + if let Some(ref req) = ext.request { + if let Some(ref env) = req.environment { + ext_map.insert(FIELD_ENVIRONMENT.into(), serde_json::json!(env)); + } + } + + // Request headers (strip sensitive) + if let Some(ref http) = ext.http { + let safe: std::collections::HashMap<&String, &String> = http + .request_headers + .iter() + .filter(|(k, _)| { + !Self::SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) + }) + .collect(); + if !safe.is_empty() { + ext_map.insert(FIELD_HEADERS.into(), serde_json::json!(safe)); + } + } + + // Agent context + if let Some(ref agent) = ext.agent { + let mut agent_map = serde_json::Map::new(); + if let Some(ref input) = agent.input { + agent_map.insert(FIELD_INPUT.into(), serde_json::json!(input)); + } + if let Some(ref sid) = agent.session_id { + agent_map.insert(FIELD_SESSION_ID.into(), serde_json::json!(sid)); + } + if let Some(ref cid) = agent.conversation_id { + agent_map.insert(FIELD_CONVERSATION_ID.into(), serde_json::json!(cid)); + } + if let Some(turn) = agent.turn { + agent_map.insert(FIELD_TURN.into(), serde_json::json!(turn)); + } + if let Some(ref aid) = agent.agent_id { + agent_map.insert(FIELD_AGENT_ID.into(), serde_json::json!(aid)); + } + if let Some(ref paid) = agent.parent_agent_id { + agent_map.insert(FIELD_PARENT_AGENT_ID.into(), serde_json::json!(paid)); + } + if !agent_map.is_empty() { + ext_map.insert(FIELD_AGENT.into(), serde_json::Value::Object(agent_map)); + } + } + + // Meta + if let Some(ref meta) = ext.meta { + let mut meta_map = serde_json::Map::new(); + if let Some(ref et) = meta.entity_type { + meta_map.insert(FIELD_ENTITY_TYPE.into(), serde_json::json!(et)); + } + if let Some(ref en) = meta.entity_name { + meta_map.insert(FIELD_ENTITY_NAME.into(), serde_json::json!(en)); + } + if !meta.tags.is_empty() { + let mut tags: Vec<&String> = meta.tags.iter().collect(); + tags.sort(); + meta_map.insert(FIELD_TAGS.into(), serde_json::json!(tags)); + } + if !meta_map.is_empty() { + ext_map.insert(FIELD_META.into(), serde_json::Value::Object(meta_map)); + } + } + + if !ext_map.is_empty() { + result.insert(FIELD_EXTENSIONS.into(), serde_json::Value::Object(ext_map)); + } + } + } + + serde_json::Value::Object(result) + } + + /// Serialize to OPA-compatible input format. + /// + /// Wraps the view in the standard OPA input envelope: + /// `{"input": {...view data...}}`. + pub fn to_opa_input(&self, include_content: bool) -> serde_json::Value { + use super::constants::FIELD_OPA_INPUT; + serde_json::json!({ + FIELD_OPA_INPUT: self.to_dict(include_content, true) + }) + } +} + +impl<'a> std::fmt::Debug for MessageView<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MessageView") + .field("kind", &self.kind) + .field("role", &self.role) + .field("name", &self.name()) + .field("hook", &self.hook) + .finish() + } +} + +// --------------------------------------------------------------------------- +// iter_views — decompose a Message into views +// --------------------------------------------------------------------------- + +/// Decompose a Message into individually addressable MessageViews. +/// +/// Yields one view per content part. Each view provides a uniform +/// interface for policy evaluation regardless of content type. +pub fn iter_views<'a>( + message: &'a Message, + hook: Option<&'a str>, + extensions: Option<&'a Extensions>, +) -> impl Iterator> { + message.content.iter().map(move |part| { + MessageView::new(part, message.role, hook, extensions) + }) +} + +// Also add iter_views to Message +impl Message { + /// Decompose this message into individually addressable MessageViews. + /// + /// Yields one view per content part. Each view provides a uniform + /// interface for policy evaluation regardless of content type. + pub fn iter_views<'a>( + &'a self, + hook: Option<&'a str>, + extensions: Option<&'a Extensions>, + ) -> impl Iterator> { + iter_views(self, hook, extensions) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmf::enums::Role; + use crate::hooks::payload::MetaExtension; + + fn make_test_message() -> Message { + Message { + schema_version: "2.0".into(), + role: Role::Assistant, + content: vec![ + ContentPart::Thinking { text: "Let me think...".into() }, + ContentPart::Text { text: "Here's the answer.".into() }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_weather".into(), + arguments: [("city".to_string(), serde_json::json!("London"))].into(), + namespace: None, + }, + }, + ContentPart::Resource { + content: Resource { + resource_request_id: "rr_001".into(), + uri: "file:///data.csv".into(), + name: Some("Data File".into()), + resource_type: crate::cmf::enums::ResourceType::File, + content: Some("col1,col2".into()), + mime_type: Some("text/csv".into()), + ..Default::default() + }, + }, + ], + channel: None, + } + } + + #[test] + fn test_iter_views_count() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, None).collect(); + assert_eq!(views.len(), 4); + } + + #[test] + fn test_view_kinds() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, None).collect(); + assert_eq!(views[0].kind(), ViewKind::Thinking); + assert_eq!(views[1].kind(), ViewKind::Text); + assert_eq!(views[2].kind(), ViewKind::ToolCall); + assert_eq!(views[3].kind(), ViewKind::Resource); + } + + #[test] + fn test_view_content() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, None).collect(); + assert_eq!(views[0].content(), Some("Let me think...")); + assert_eq!(views[1].content(), Some("Here's the answer.")); + assert!(views[2].content().is_none()); // tool call has no text content + assert_eq!(views[3].content(), Some("col1,col2")); // resource has text content + } + + #[test] + fn test_view_name() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, None).collect(); + assert!(views[0].name().is_none()); // thinking has no name + assert!(views[1].name().is_none()); // text has no name + assert_eq!(views[2].name(), Some("get_weather")); + assert_eq!(views[3].name(), Some("Data File")); + } + + #[test] + fn test_view_uri() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, None).collect(); + assert_eq!(views[2].uri(), Some("tool://_/get_weather".to_string())); + assert_eq!(views[3].uri(), Some("file:///data.csv".to_string())); + } + + #[test] + fn test_view_args() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, None).collect(); + let tool_view = &views[2]; + assert!(tool_view.has_arg("city")); + assert_eq!(tool_view.get_arg("city").unwrap(), "London"); + assert!(!tool_view.has_arg("nonexistent")); + } + + #[test] + fn test_view_action() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, None).collect(); + assert_eq!(views[0].action(), ViewAction::Generate); // thinking from assistant + assert_eq!(views[1].action(), ViewAction::Generate); // text from assistant + assert_eq!(views[2].action(), ViewAction::Execute); // tool call + assert_eq!(views[3].action(), ViewAction::Read); // resource + } + + #[test] + fn test_view_action_user_role() { + let msg = Message::text(Role::User, "Hello"); + let views: Vec<_> = msg.iter_views(None, None).collect(); + assert_eq!(views[0].action(), ViewAction::Send); // text from user + } + + #[test] + fn test_view_hook_pre_post() { + let msg = make_test_message(); + let pre_views: Vec<_> = msg.iter_views(Some("tool_pre_invoke"), None).collect(); + assert!(pre_views[0].is_pre()); + assert!(!pre_views[0].is_post()); + + let post_views: Vec<_> = msg.iter_views(Some("tool_post_invoke"), None).collect(); + assert!(post_views[0].is_post()); + assert!(!post_views[0].is_pre()); + } + + #[test] + fn test_view_type_helpers() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, None).collect(); + assert!(views[0].is_text()); // thinking + assert!(views[1].is_text()); // text + assert!(views[2].is_tool()); // tool call + assert!(views[3].is_resource()); // resource + } + + #[test] + fn test_view_mime_type() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, None).collect(); + assert_eq!(views[3].mime_type(), Some("text/csv")); + } + + #[test] + fn test_view_with_extensions() { + use std::sync::Arc; + use crate::extensions::{SecurityExtension, HttpExtension}; + + let mut security = SecurityExtension::default(); + security.add_label("PII"); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer tok"); + + let ext = Extensions { + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + ..Default::default() + }; + + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(None, Some(&ext)).collect(); + + assert!(views[0].has_label("PII")); + assert!(!views[0].has_label("HIPAA")); + assert_eq!(views[0].get_header("Authorization"), Some("Bearer tok")); + } + + #[test] + fn test_to_dict_basic() { + let msg = Message::text(Role::User, "Hello world"); + let views: Vec<_> = msg.iter_views(Some("llm_input"), None).collect(); + let dict = views[0].to_dict(true, false); + + assert_eq!(dict["kind"], "text"); + assert_eq!(dict["role"], "user"); + assert_eq!(dict["action"], "send"); + assert_eq!(dict["hook"], "llm_input"); + assert_eq!(dict["content"], "Hello world"); + assert_eq!(dict["size_bytes"], 11); + assert_eq!(dict["is_pre"], false); + assert_eq!(dict["is_post"], false); + } + + #[test] + fn test_to_dict_tool_call() { + let msg = make_test_message(); + let views: Vec<_> = msg.iter_views(Some("tool_pre_invoke"), None).collect(); + let dict = views[2].to_dict(true, false); // tool call + + assert_eq!(dict["kind"], "tool_call"); + assert_eq!(dict["name"], "get_weather"); + assert_eq!(dict["uri"], "tool://_/get_weather"); + assert_eq!(dict["action"], "execute"); + assert_eq!(dict["is_pre"], true); + assert!(dict["arguments"].is_object()); + assert_eq!(dict["arguments"]["city"], "London"); + } + + #[test] + fn test_to_dict_without_content() { + let msg = Message::text(Role::User, "Secret message"); + let views: Vec<_> = msg.iter_views(None, None).collect(); + let dict = views[0].to_dict(false, false); + + assert!(dict.get("content").is_none()); + assert!(dict.get("size_bytes").is_none()); + } + + #[test] + fn test_to_dict_with_extensions() { + use std::sync::Arc; + use crate::extensions::{ + SecurityExtension, HttpExtension, RequestExtension, AgentExtension, + }; + + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.subject = Some(crate::extensions::security::SubjectExtension { + id: Some("alice".into()), + subject_type: Some(crate::extensions::security::SubjectType::User), + roles: ["admin".to_string()].into(), + ..Default::default() + }); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer secret"); + http.set_header("X-Request-ID", "req-123"); + + let ext = Extensions { + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + request: Some(Arc::new(RequestExtension { + environment: Some("production".into()), + ..Default::default() + })), + agent: Some(Arc::new(AgentExtension { + session_id: Some("sess-001".into()), + agent_id: Some("agent-x".into()), + ..Default::default() + })), + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + tags: ["pii".to_string()].into(), + ..Default::default() + })), + ..Default::default() + }; + + let msg = Message::text(Role::User, "test"); + let views: Vec<_> = msg.iter_views(None, Some(&ext)).collect(); + let dict = views[0].to_dict(true, true); + + let extensions = &dict["extensions"]; + + // Subject visible + assert_eq!(extensions["subject"]["id"], "alice"); + assert!(extensions["subject"]["roles"].as_array().unwrap().contains(&serde_json::json!("admin"))); + + // Labels visible + assert!(extensions["labels"].as_array().unwrap().contains(&serde_json::json!("PII"))); + + // Environment visible + assert_eq!(extensions["environment"], "production"); + + // Headers visible — but Authorization stripped (sensitive) + assert!(extensions["headers"].get("Authorization").is_none()); + assert_eq!(extensions["headers"]["X-Request-ID"], "req-123"); + + // Agent context visible + assert_eq!(extensions["agent"]["session_id"], "sess-001"); + assert_eq!(extensions["agent"]["agent_id"], "agent-x"); + + // Meta visible + assert_eq!(extensions["meta"]["entity_type"], "tool"); + assert_eq!(extensions["meta"]["entity_name"], "get_compensation"); + } + + #[test] + fn test_to_opa_input() { + let msg = Message::text(Role::User, "Hello"); + let views: Vec<_> = msg.iter_views(None, None).collect(); + let opa = views[0].to_opa_input(true); + + assert!(opa.get("input").is_some()); + assert_eq!(opa["input"]["kind"], "text"); + assert_eq!(opa["input"]["role"], "user"); + assert_eq!(opa["input"]["content"], "Hello"); + } +} diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index 9a6cdcd2..20a80a86 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -34,7 +34,8 @@ use tokio::time::timeout; use tracing::{error, warn}; use crate::context::{PluginContext, PluginContextTable}; -use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; +use crate::extensions::filter_extensions; +use crate::hooks::payload::{Extensions, PluginPayload, WriteToken}; use crate::plugin::OnError; use crate::registry::{group_by_mode, HookEntry}; @@ -329,6 +330,7 @@ impl Executor { let bg_handles = self.spawn_fire_and_forget( &fire_and_forget, &*current_payload, + ¤t_extensions, &ctx_table, ); @@ -381,10 +383,30 @@ impl Executor { let mut ctx = ctx_table.remove(&plugin_id).unwrap_or_default(); ctx.global_state = global_state.clone(); - // TODO: Capability-filter extensions per plugin (Phase 3) - let filtered = FilteredExtensions::default(); + // Filter extensions per plugin based on declared capabilities. + // Produces a filtered view with None for ungated slots. + // Also sets write tokens for plugins with write capabilities. + let capabilities: std::collections::HashSet = entry + .plugin_ref + .trusted_config() + .capabilities + .iter() + .cloned() + .collect(); + let mut filtered = filter_extensions(extensions, &capabilities); + + // Set write tokens based on capabilities + if capabilities.contains("write_headers") { + filtered.http_write_token = Some(WriteToken::new()); + } + if capabilities.contains("append_labels") { + filtered.labels_write_token = Some(WriteToken::new()); + } + if capabilities.contains("append_delegation") { + filtered.delegation_write_token = Some(WriteToken::new()); + } - // Execute with timeout — handler borrows the payload + // Execute with timeout — handler borrows payload, gets filtered extensions let timeout_dur = Duration::from_secs(self.config.timeout_seconds); let result = timeout(timeout_dur, entry.handler.invoke(&**payload, &filtered, &mut ctx)) .await; @@ -405,9 +427,33 @@ impl Executor { if let Some(mp) = erased.modified_payload { *payload = mp; } - if let Some(me) = erased.modified_extensions { - // TODO: Merge with tier validation (Phase 3) - *extensions = me; + if let Some(owned) = erased.modified_extensions { + // Validate tier constraints before accepting + if !extensions.validate_immutable(&owned) { + warn!( + "{} plugin '{}' violated immutable tier — \ + modified an immutable extension slot. \ + Extension changes rejected.", + phase_label, plugin_name + ); + } else if let Some(ref orig_sec) = extensions.security { + if let Some(ref new_sec) = owned.security { + if !new_sec.labels.is_superset(&orig_sec.labels) { + warn!( + "{} plugin '{}' violated monotonic tier — \ + removed a security label. \ + Extension changes rejected.", + phase_label, plugin_name + ); + } else { + extensions.merge_owned(owned); + } + } else { + extensions.merge_owned(owned); + } + } else { + extensions.merge_owned(owned); + } } } @@ -479,7 +525,7 @@ impl Executor { &self, entries: &[HookEntry], payload: &dyn PluginPayload, - _extensions: &Extensions, + extensions: &Extensions, ctx_table: &PluginContextTable, phase_label: &str, ) { @@ -498,14 +544,22 @@ impl Executor { .cloned() .map(|mut c| { c.global_state = global_state.clone(); c }) .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); - let filtered = FilteredExtensions::default(); + // Filter extensions per plugin — read-only, no write tokens. + let capabilities: std::collections::HashSet = entry + .plugin_ref + .trusted_config() + .capabilities + .iter() + .cloned() + .collect(); + let filtered = filter_extensions(extensions, &capabilities); let timeout_dur = Duration::from_secs(self.config.timeout_seconds); let result = timeout(timeout_dur, entry.handler.invoke(payload, &filtered, &mut ctx)) .await; match result { - Ok(Ok(_)) => {} // read-only — discard result + Ok(Ok(_)) => {} // read-only — discard result and ext_clone Ok(Err(e)) => { warn!("{} plugin '{}' error (ignored): {}", phase_label, plugin_name, e); } @@ -526,7 +580,7 @@ impl Executor { &self, entries: &[HookEntry], payload: &dyn PluginPayload, - _extensions: &Extensions, + extensions: &Extensions, ctx_table: &PluginContextTable, ) -> Option { if entries.is_empty() { @@ -562,8 +616,18 @@ impl Executor { .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); let dur = timeout_dur; + // Filter per plugin — each may have different capabilities. + // Read-only, no write tokens. Wrap in Arc for 'static spawn. + let capabilities: std::collections::HashSet = entry + .plugin_ref + .trusted_config() + .capabilities + .iter() + .cloned() + .collect(); + let filtered = Arc::new(filter_extensions(extensions, &capabilities)); + let handle = tokio::spawn(async move { - let filtered = FilteredExtensions::default(); timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await }); @@ -662,6 +726,7 @@ impl Executor { &self, entries: &[HookEntry], payload: &dyn PluginPayload, + extensions: &Extensions, ctx_table: &PluginContextTable, ) -> Vec<(String, tokio::task::JoinHandle<()>)> { if entries.is_empty() { @@ -685,8 +750,17 @@ impl Executor { let dur = timeout_dur; let name_for_log = plugin_name.clone(); + // Filter per plugin, read-only, no write tokens + let capabilities: std::collections::HashSet = entry + .plugin_ref + .trusted_config() + .capabilities + .iter() + .cloned() + .collect(); + let filtered = Arc::new(filter_extensions(extensions, &capabilities)); + let handle = tokio::spawn(async move { - let filtered = FilteredExtensions::default(); let result = timeout( dur, handler.invoke(&*owned_payload, &filtered, &mut ctx), @@ -735,7 +809,7 @@ impl Default for Executor { pub struct ErasedResultFields { pub continue_processing: bool, pub modified_payload: Option>, - pub modified_extensions: Option, + pub modified_extensions: Option, pub violation: Option, } @@ -817,14 +891,20 @@ mod tests { #[test] fn test_erase_result_modify_extensions() { - let mut ext = Extensions::default(); - ext.labels.insert("PII".into()); - let result: PluginResult = PluginResult::modify_extensions(ext); + let mut security = crate::extensions::SecurityExtension::default(); + security.add_label("PII"); + let ext = Extensions { + security: Some(Arc::new(security)), + ..Default::default() + }; + let owned = ext.cow_copy(); + let result: PluginResult = PluginResult::modify_extensions(owned); let erased = erase_result(result); let fields = extract_erased(erased).unwrap(); assert!(fields.continue_processing); assert!(fields.modified_extensions.is_some()); - assert!(fields.modified_extensions.as_ref().unwrap().labels.contains("PII")); + let sec = fields.modified_extensions.as_ref().unwrap().security.as_ref().unwrap(); + assert!(sec.has_label("PII")); } #[test] diff --git a/crates/cpex-core/src/extensions/agent.rs b/crates/cpex-core/src/extensions/agent.rs new file mode 100644 index 00000000..f6eb9c3b --- /dev/null +++ b/crates/cpex-core/src/extensions/agent.rs @@ -0,0 +1,60 @@ +// Location: ./crates/cpex-core/src/extensions/agent.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// AgentExtension — session, conversation, agent lineage. +// Mirrors cpex/framework/extensions/agent.py. + +use serde::{Deserialize, Serialize}; + +/// Conversation history context. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConversationContext { + /// Recent conversation history (lightweight summaries). + #[serde(default)] + pub history: Vec, + + /// LLM-generated summary of the conversation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + + /// Detected topics in the conversation. + #[serde(default)] + pub topics: Vec, +} + +/// Agent execution context extension. +/// +/// Carries session tracking, conversation context, multi-agent +/// lineage, and the original user/agent input. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AgentExtension { + /// Original user/agent input that triggered this action. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + + /// Broad user/agent session identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + + /// Specific dialogue/task identifier within a session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + + /// Position within the conversation (0-indexed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn: Option, + + /// Identifier of the agent that produced this message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + + /// If spawned by another agent, the parent's ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_agent_id: Option, + + /// Optional conversation context with history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conversation: Option, +} diff --git a/crates/cpex-core/src/extensions/completion.rs b/crates/cpex-core/src/extensions/completion.rs new file mode 100644 index 00000000..2c3ad14d --- /dev/null +++ b/crates/cpex-core/src/extensions/completion.rs @@ -0,0 +1,71 @@ +// Location: ./crates/cpex-core/src/extensions/completion.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CompletionExtension — LLM completion information. +// Mirrors cpex/framework/extensions/completion.py. + +use serde::{Deserialize, Serialize}; + +/// Why the model stopped generating. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StopReason { + /// Natural end of message. + End, + /// Complete response (Harmony format). + Return, + /// Tool/function invocation. + Call, + /// Hit token limit. + MaxTokens, + /// Hit custom stop sequence. + StopSequence, +} + +/// Token usage statistics. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TokenUsage { + /// Input tokens consumed. + #[serde(default)] + pub input_tokens: u32, + + /// Output tokens generated. + #[serde(default)] + pub output_tokens: u32, + + /// Total tokens (input + output). + #[serde(default)] + pub total_tokens: u32, +} + +/// LLM completion information. +/// +/// Immutable — set after the LLM responds. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CompletionExtension { + /// Why the model stopped generating. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + + /// Token usage statistics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + /// Model identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Raw response format (chatml, harmony, gemini, anthropic). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_format: Option, + + /// Creation timestamp (ISO 8601). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + + /// Response latency in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latency_ms: Option, +} diff --git a/crates/cpex-core/src/extensions/container.rs b/crates/cpex-core/src/extensions/container.rs new file mode 100644 index 00000000..68f0f3a5 --- /dev/null +++ b/crates/cpex-core/src/extensions/container.rs @@ -0,0 +1,532 @@ +// Location: ./crates/cpex-core/src/extensions/container.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Extensions and OwnedExtensions — typed containers for all +// extension data passed separately from the payload to handlers. +// +// Extensions is fully immutable (all Arc) — zero-copy shareable. +// OwnedExtensions is the plugin's writeable workspace, created by +// cow_copy(), returned in PluginResult::modify_extensions(). + +use std::collections::HashMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use super::agent::AgentExtension; +use super::completion::CompletionExtension; +use super::delegation::DelegationExtension; +use super::framework::FrameworkExtension; +use super::guarded::{Guarded, WriteToken}; +use super::http::HttpExtension; +use super::llm::LLMExtension; +use super::mcp::MCPExtension; +use super::meta::MetaExtension; +use super::provenance::ProvenanceExtension; +use super::request::RequestExtension; +use super::security::SecurityExtension; + +// --------------------------------------------------------------------------- +// Extensions — all Arc, fully immutable, zero-copy shareable +// --------------------------------------------------------------------------- + +/// Typed container for all message extensions. +/// +/// All slots are `Arc` — fully immutable, zero-copy shareable. +/// Cloning is all refcount bumps. `filter_extensions()` creates a +/// filtered view by setting unwanted slots to `None` (still all Arc, +/// no deep copies). Plugins receive `&Extensions` (zero cost). +/// +/// To modify, plugins call `cow_copy()` which returns an +/// `OwnedExtensions` with mutable/monotonic/guarded slots cloned +/// out of Arc and write tokens propagated. +/// +/// Mirrors Python's `cpex.framework.extensions.Extensions`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Extensions { + /// Execution environment and request tracing (immutable). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option>, + + /// Agent execution context — session, conversation, lineage (immutable). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option>, + + /// HTTP headers (frozen as Arc — unfrozen in OwnedExtensions). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option>, + + /// Security — labels, classification, subject (frozen as Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security: Option>, + + /// Delegation chain (frozen as Arc). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation: Option>, + + /// MCP entity metadata (immutable). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp: Option>, + + /// LLM completion information (immutable). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion: Option>, + + /// Origin and message threading (immutable). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option>, + + /// Model identity and capabilities (immutable). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm: Option>, + + /// Agentic framework context (immutable). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub framework: Option>, + + /// Host-provided operational metadata (immutable). + #[serde(default)] + pub meta: Option>, + + /// Custom extensions (frozen as Arc — unfrozen in OwnedExtensions). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom: Option>>, + + /// Write tokens — set by the executor per plugin, NOT serialized. + /// Used by `cow_copy()` to propagate write access to OwnedExtensions. + #[serde(skip)] + pub http_write_token: Option, + #[serde(skip)] + pub labels_write_token: Option, + #[serde(skip)] + pub delegation_write_token: Option, +} + +impl Clone for Extensions { + /// All Arc bumps — zero data copies. Write tokens are NOT cloned. + fn clone(&self) -> Self { + Self { + request: self.request.clone(), + agent: self.agent.clone(), + http: self.http.clone(), + security: self.security.clone(), + delegation: self.delegation.clone(), + mcp: self.mcp.clone(), + completion: self.completion.clone(), + provenance: self.provenance.clone(), + llm: self.llm.clone(), + framework: self.framework.clone(), + meta: self.meta.clone(), + custom: self.custom.clone(), + http_write_token: None, + labels_write_token: None, + delegation_write_token: None, + } + } +} + +impl Extensions { + /// Create a copy-on-write owned copy for modification. + /// + /// Immutable slots share the same `Arc` (refcount bump, ~1ns). + /// Mutable/monotonic/guarded slots are cloned out of Arc into + /// owned values — the plugin can modify them directly. + /// Write tokens are propagated from the original. + /// + /// # Usage + /// + /// ```ignore + /// fn handle(&self, payload: &P, ext: &Extensions, ctx: &mut PluginContext) -> PluginResult

{ + /// let mut owned = ext.cow_copy(); + /// owned.security.as_mut().unwrap().add_label("CHECKED"); + /// if let Some(ref token) = owned.http_write_token { + /// owned.http.as_mut().unwrap().write(token).set_header("X-Foo", "bar"); + /// } + /// PluginResult::modify_extensions(owned) + /// } + /// ``` + pub fn cow_copy(&self) -> OwnedExtensions { + OwnedExtensions { + // Immutable — same Arc pointers + request: self.request.clone(), + agent: self.agent.clone(), + mcp: self.mcp.clone(), + completion: self.completion.clone(), + provenance: self.provenance.clone(), + llm: self.llm.clone(), + framework: self.framework.clone(), + meta: self.meta.clone(), + + // Mutable/monotonic/guarded — cloned out of Arc into owned + http: self.http.as_ref().map(|arc| Guarded::new((**arc).clone())), + security: self.security.as_ref().map(|arc| (**arc).clone()), + delegation: self.delegation.as_ref().map(|arc| (**arc).clone()), + custom: self.custom.as_ref().map(|arc| (**arc).clone()), + + // Write tokens — propagated from the original + http_write_token: if self.http_write_token.is_some() { + Some(WriteToken::new()) + } else { + None + }, + labels_write_token: if self.labels_write_token.is_some() { + Some(WriteToken::new()) + } else { + None + }, + delegation_write_token: if self.delegation_write_token.is_some() { + Some(WriteToken::new()) + } else { + None + }, + } + } + + /// Validate that immutable slots were not tampered with. + pub fn validate_immutable(&self, modified: &OwnedExtensions) -> bool { + fn ptr_eq_opt(a: &Option>, b: &Option>) -> bool { + match (a, b) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + } + } + + ptr_eq_opt(&self.request, &modified.request) + && ptr_eq_opt(&self.agent, &modified.agent) + && ptr_eq_opt(&self.mcp, &modified.mcp) + && ptr_eq_opt(&self.completion, &modified.completion) + && ptr_eq_opt(&self.provenance, &modified.provenance) + && ptr_eq_opt(&self.llm, &modified.llm) + && ptr_eq_opt(&self.framework, &modified.framework) + && ptr_eq_opt(&self.meta, &modified.meta) + } + + /// Merge an OwnedExtensions back into this Extensions. + pub fn merge_owned(&mut self, owned: OwnedExtensions) { + self.http = owned.http.map(|g| Arc::new(g.into_inner())); + self.security = owned.security.map(Arc::new); + self.delegation = owned.delegation.map(Arc::new); + self.custom = owned.custom.map(Arc::new); + } +} + +// --------------------------------------------------------------------------- +// OwnedExtensions — plugin's writeable workspace +// --------------------------------------------------------------------------- + +/// Owned copy of extensions for plugin modification. +/// +/// Returned by `Extensions::cow_copy()`. Immutable slots share +/// the same `Arc` pointers as the original (zero copy). Mutable, +/// monotonic, and guarded slots are cloned into owned values that +/// the plugin can modify directly. +/// +/// Plugins return this in `PluginResult::modify_extensions()`. +/// The executor validates (immutable unchanged, monotonic superset) +/// and merges back into the pipeline's `Extensions`. +/// +/// Hosts never see this type — the executor converts to `Extensions` +/// before building `PipelineResult`. +#[derive(Debug)] +pub struct OwnedExtensions { + // Immutable — same Arc pointers as original + pub request: Option>, + pub agent: Option>, + pub mcp: Option>, + pub completion: Option>, + pub provenance: Option>, + pub llm: Option>, + pub framework: Option>, + pub meta: Option>, + + // Mutable/monotonic/guarded — owned, modifiable + pub http: Option>, + pub security: Option, + pub delegation: Option, + pub custom: Option>, + + // Write tokens — propagated from executor + pub http_write_token: Option, + pub labels_write_token: Option, + pub delegation_write_token: Option, +} +#[cfg(test)] +mod tests { + use super::*; + use crate::extensions::{ + DelegationExtension, HttpExtension, RequestExtension, SecurityExtension, + }; + + fn make_extensions() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer token"); + + Extensions { + request: Some(Arc::new(RequestExtension { + request_id: Some("req-001".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + delegation: Some(Arc::new(DelegationExtension::default())), + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + ..Default::default() + })), + ..Default::default() + } + } + + #[test] + fn test_cow_copy_shares_immutable_arcs() { + let ext = make_extensions(); + let cow = ext.cow_copy(); + + // Immutable slots share the same Arc — zero copy + assert!(Arc::ptr_eq(ext.request.as_ref().unwrap(), cow.request.as_ref().unwrap())); + assert!(Arc::ptr_eq(ext.meta.as_ref().unwrap(), cow.meta.as_ref().unwrap())); + } + + #[test] + fn test_cow_copy_deep_clones_mutable_slots() { + let ext = make_extensions(); + let cow = ext.cow_copy(); + + // Mutable/monotonic slots are deep cloned — independent copies + assert!(cow.security.is_some()); + assert!(cow.http.is_some()); + assert!(cow.delegation.is_some()); + + // Modifying the COW copy doesn't affect the original + cow.security.as_ref().unwrap().has_label("PII"); + } + + #[test] + fn test_cow_copy_propagates_write_tokens() { + let mut ext = make_extensions(); + + // No tokens on the original → no tokens on COW + let cow_no_tokens = ext.cow_copy(); + assert!(cow_no_tokens.http_write_token.is_none()); + assert!(cow_no_tokens.labels_write_token.is_none()); + assert!(cow_no_tokens.delegation_write_token.is_none()); + + // Executor sets tokens based on capabilities + ext.http_write_token = Some(WriteToken::new()); + ext.labels_write_token = Some(WriteToken::new()); + + // COW copy propagates only the tokens that exist + let cow_with_tokens = ext.cow_copy(); + assert!(cow_with_tokens.http_write_token.is_some()); + assert!(cow_with_tokens.labels_write_token.is_some()); + assert!(cow_with_tokens.delegation_write_token.is_none()); // wasn't set + } + + #[test] + fn test_cow_copy_write_token_enables_guarded_write() { + let mut ext = make_extensions(); + ext.http_write_token = Some(WriteToken::new()); + + let mut cow = ext.cow_copy(); + + // Can read without token + assert_eq!( + cow.http.as_ref().unwrap().read().get_header("Authorization"), + Some("Bearer token") + ); + + // Can write with token from COW + let token = cow.http_write_token.as_ref().unwrap(); + cow.http + .as_mut() + .unwrap() + .write(token) + .set_header("X-Custom", "value"); + + assert_eq!( + cow.http.as_ref().unwrap().read().get_header("X-Custom"), + Some("value") + ); + + // Original unchanged + assert!(ext.http.as_ref().unwrap().get_header("X-Custom").is_none()); + } + + #[test] + fn test_cow_copy_monotonic_label_insert() { + let mut ext = make_extensions(); + ext.labels_write_token = Some(WriteToken::new()); + + let mut cow = ext.cow_copy(); + + // Can add labels on the COW copy + cow.security.as_mut().unwrap().add_label("HIPAA"); + assert!(cow.security.as_ref().unwrap().has_label("HIPAA")); + + // Original unchanged + assert!(!ext.security.as_ref().unwrap().has_label("HIPAA")); + } + + #[test] + fn test_validate_immutable_passes_for_cow() { + let ext = make_extensions(); + let cow = ext.cow_copy(); + + // COW copy shares immutable Arcs → validation passes + assert!(ext.validate_immutable(&cow)); + } + + #[test] + fn test_validate_immutable_fails_when_tampered() { + let ext = make_extensions(); + let mut cow = ext.cow_copy(); + + // Tamper with an immutable slot + cow.request = Some(Arc::new(RequestExtension { + request_id: Some("TAMPERED".into()), + ..Default::default() + })); + + // Validation fails — different Arc pointer + assert!(!ext.validate_immutable(&cow)); + } + + #[test] + fn test_validate_immutable_both_none_passes() { + let ext = Extensions::default(); + let cow = ext.cow_copy(); + assert!(ext.validate_immutable(&cow)); + } + + #[test] + fn test_clone_drops_write_tokens() { + let mut ext = make_extensions(); + ext.http_write_token = Some(WriteToken::new()); + ext.labels_write_token = Some(WriteToken::new()); + ext.delegation_write_token = Some(WriteToken::new()); + + // Regular clone drops all tokens + let cloned = ext.clone(); + assert!(cloned.http_write_token.is_none()); + assert!(cloned.labels_write_token.is_none()); + assert!(cloned.delegation_write_token.is_none()); + + // cow_copy propagates them + let cow = ext.cow_copy(); + assert!(cow.http_write_token.is_some()); + assert!(cow.labels_write_token.is_some()); + assert!(cow.delegation_write_token.is_some()); + } + + #[test] + fn test_cow_copy_modify_multiple_fields() { + use crate::extensions::DelegationExtension; + use crate::extensions::delegation::DelegationHop; + + // Build extensions with security, http, delegation, custom + let mut security = SecurityExtension::default(); + security.add_label("PII"); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer token"); + + let mut ext = Extensions { + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + delegation: Some(Arc::new(DelegationExtension::default())), + custom: Some(Arc::new([("existing".to_string(), serde_json::json!("value"))].into())), + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + ..Default::default() + })), + ..Default::default() + }; + + // Executor sets all write tokens + ext.http_write_token = Some(WriteToken::new()); + ext.labels_write_token = Some(WriteToken::new()); + ext.delegation_write_token = Some(WriteToken::new()); + + // Plugin does one cow_copy, modifies multiple fields + let mut cow = ext.cow_copy(); + + // 1. Add security labels (monotonic) + cow.security.as_mut().unwrap().add_label("CHECKED"); + cow.security.as_mut().unwrap().add_label("COMPLIANT"); + + // 2. Inject HTTP headers (guarded) + let token = cow.http_write_token.as_ref().unwrap(); + cow.http.as_mut().unwrap().write(token).set_header("X-Checked", "true"); + cow.http.as_mut().unwrap().write(token).set_header("X-Policy", "v2"); + + // 3. Append delegation hop (monotonic) + cow.delegation.as_mut().unwrap().append_hop(DelegationHop { + subject_id: "service-a".into(), + scopes_granted: vec!["read_hr".into()], + ..Default::default() + }); + + // 4. Add custom data (mutable, no token needed) + cow.custom.as_mut().unwrap().insert( + "audit.timestamp".into(), + serde_json::json!("2026-04-29"), + ); + + // Verify COW copy has all modifications + let sec = cow.security.as_ref().unwrap(); + assert!(sec.has_label("PII")); // original + assert!(sec.has_label("CHECKED")); // added + assert!(sec.has_label("COMPLIANT")); // added + + let http = cow.http.as_ref().unwrap().read(); + assert_eq!(http.get_header("Authorization"), Some("Bearer token")); // original + assert_eq!(http.get_header("X-Checked"), Some("true")); // added + assert_eq!(http.get_header("X-Policy"), Some("v2")); // added + + assert_eq!(cow.delegation.as_ref().unwrap().chain.len(), 1); + assert_eq!(cow.delegation.as_ref().unwrap().chain[0].subject_id, "service-a"); + + assert_eq!(cow.custom.as_ref().unwrap().get("existing").unwrap(), "value"); + assert_eq!(cow.custom.as_ref().unwrap().get("audit.timestamp").unwrap(), "2026-04-29"); + + // Verify original is unchanged + assert!(!ext.security.as_ref().unwrap().has_label("CHECKED")); + assert!(ext.http.as_ref().unwrap().get_header("X-Checked").is_none()); + assert!(ext.delegation.as_ref().unwrap().chain.is_empty()); + assert!(!ext.custom.as_ref().unwrap().contains_key("audit.timestamp")); + + // Immutable slots still valid + assert!(ext.validate_immutable(&cow)); + } + + #[test] + fn test_read_only_plugin_zero_cost() { + // Plugin that only reads — no cow_copy, no clone + let ext = make_extensions(); + + // Read security labels + let has_pii = ext.security.as_ref() + .map(|s| s.has_label("PII")) + .unwrap_or(false); + assert!(has_pii); + + // Read HTTP headers + let auth = ext.http.as_ref() + .map(|h| h.get_header("Authorization")) + .flatten(); + assert_eq!(auth, Some("Bearer token")); + + // Read meta + let entity = ext.meta.as_ref() + .and_then(|m| m.entity_type.as_deref()); + assert_eq!(entity, Some("tool")); + + // No cow_copy called — zero allocations for read-only access + } +} diff --git a/crates/cpex-core/src/extensions/delegation.rs b/crates/cpex-core/src/extensions/delegation.rs new file mode 100644 index 00000000..2921cdce --- /dev/null +++ b/crates/cpex-core/src/extensions/delegation.rs @@ -0,0 +1,161 @@ +// Location: ./crates/cpex-core/src/extensions/delegation.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// DelegationExtension — token delegation chain. +// Mirrors cpex/framework/extensions/delegation.py. + +use serde::{Deserialize, Serialize}; + +/// A single hop in the delegation chain. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DelegationHop { + /// Subject ID of the delegator. + pub subject_id: String, + + /// Subject type of the delegator. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_type: Option, + + /// Target audience. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audience: Option, + + /// Scopes granted in this delegation step. + #[serde(default)] + pub scopes_granted: Vec, + + /// Timestamp of delegation (ISO 8601). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + + /// Time-to-live in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, + + /// Delegation strategy used. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strategy: Option, + + /// Whether this hop was resolved from cache. + #[serde(default)] + pub from_cache: bool, +} + +/// Delegation chain extension. +/// +/// Append-only — each hop narrows scope. A delegate cannot have +/// more permissions than the delegator. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DelegationExtension { + /// Ordered delegation chain. + #[serde(default)] + pub chain: Vec, + + /// Chain depth (number of hops). + #[serde(default)] + pub depth: usize, + + /// Subject ID of the original delegator. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_subject_id: Option, + + /// Subject ID of the current actor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor_subject_id: Option, + + /// Whether delegation has occurred. + #[serde(default)] + pub delegated: bool, + + /// Age of the delegation chain in seconds. + #[serde(default)] + pub age_seconds: f64, +} + +impl DelegationExtension { + /// Append a delegation hop (monotonic — cannot remove). + pub fn append_hop(&mut self, hop: DelegationHop) { + self.chain.push(hop); + self.depth = self.chain.len(); + self.delegated = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_delegation_starts_empty() { + let del = DelegationExtension::default(); + assert!(del.chain.is_empty()); + assert_eq!(del.depth, 0); + assert!(!del.delegated); + } + + #[test] + fn test_append_hop() { + let mut del = DelegationExtension::default(); + del.append_hop(DelegationHop { + subject_id: "alice".into(), + scopes_granted: vec!["read_hr".into()], + ..Default::default() + }); + + assert_eq!(del.chain.len(), 1); + assert_eq!(del.depth, 1); + assert!(del.delegated); + assert_eq!(del.chain[0].subject_id, "alice"); + assert_eq!(del.chain[0].scopes_granted, vec!["read_hr"]); + } + + #[test] + fn test_append_multiple_hops() { + let mut del = DelegationExtension::default(); + del.origin_subject_id = Some("alice".into()); + + del.append_hop(DelegationHop { + subject_id: "alice".into(), + audience: Some("service-b".into()), + scopes_granted: vec!["read".into(), "write".into()], + strategy: Some("token_exchange".into()), + ..Default::default() + }); + + del.append_hop(DelegationHop { + subject_id: "service-b".into(), + audience: Some("service-c".into()), + scopes_granted: vec!["read".into()], // narrowed scope + ..Default::default() + }); + + assert_eq!(del.chain.len(), 2); + assert_eq!(del.depth, 2); + // Second hop has narrower scope + assert_eq!(del.chain[1].scopes_granted, vec!["read"]); + } + + #[test] + fn test_delegation_serde_roundtrip() { + let mut del = DelegationExtension::default(); + del.origin_subject_id = Some("alice".into()); + del.actor_subject_id = Some("service-b".into()); + del.append_hop(DelegationHop { + subject_id: "alice".into(), + subject_type: Some("user".into()), + scopes_granted: vec!["admin".into()], + from_cache: true, + ..Default::default() + }); + + let json = serde_json::to_string(&del).unwrap(); + let deserialized: DelegationExtension = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.depth, 1); + assert!(deserialized.delegated); + assert_eq!(deserialized.origin_subject_id.as_deref(), Some("alice")); + assert!(deserialized.chain[0].from_cache); + } +} diff --git a/crates/cpex-core/src/extensions/filter.rs b/crates/cpex-core/src/extensions/filter.rs new file mode 100644 index 00000000..18bca78b --- /dev/null +++ b/crates/cpex-core/src/extensions/filter.rs @@ -0,0 +1,549 @@ +// Location: ./crates/cpex-core/src/extensions/filter.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Extension filtering — capability-gated visibility. +// +// Builds a Extensions from Extensions + declared capabilities. +// Secure by default: slots not explicitly included are None. +// +// Mirrors cpex/framework/extensions/tiers.py::filter_extensions(). + +use std::collections::HashSet; +use std::sync::Arc; + +use super::container::Extensions; + +use super::security::{SecurityExtension, SubjectExtension}; +use super::tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; + +// --------------------------------------------------------------------------- +// Slot Registry — static policies per extension slot +// --------------------------------------------------------------------------- + +/// Extension slot identifiers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SlotName { + Request, + Agent, + Http, + Meta, + Delegation, + Custom, + Mcp, + Completion, + Provenance, + Llm, + Framework, + // Security sub-slots + SecurityLabels, + SecuritySubject, + SecuritySubjectRoles, + SecuritySubjectTeams, + SecuritySubjectClaims, + SecuritySubjectPermissions, + SecurityObjects, + SecurityData, +} + +/// Get the policy for a given slot. +pub fn slot_policy(slot: SlotName) -> SlotPolicy { + match slot { + // Unrestricted immutable — always visible + SlotName::Request => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Provenance => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Completion => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Llm => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Framework => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Mcp => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Meta => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::Custom => SlotPolicy { + tier: MutabilityTier::Mutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + // Capability-gated + SlotName::Agent => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadAgent), + write_cap: None, + }, + SlotName::Http => SlotPolicy { + tier: MutabilityTier::Mutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadHeaders), + write_cap: Some(Capability::WriteHeaders), + }, + SlotName::Delegation => SlotPolicy { + tier: MutabilityTier::Monotonic, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadDelegation), + write_cap: Some(Capability::AppendDelegation), + }, + // Security sub-slots + SlotName::SecurityLabels => SlotPolicy { + tier: MutabilityTier::Monotonic, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadLabels), + write_cap: Some(Capability::AppendLabels), + }, + SlotName::SecuritySubject => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadSubject), + write_cap: None, + }, + SlotName::SecuritySubjectRoles => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadRoles), + write_cap: None, + }, + SlotName::SecuritySubjectTeams => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadTeams), + write_cap: None, + }, + SlotName::SecuritySubjectClaims => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadClaims), + write_cap: None, + }, + SlotName::SecuritySubjectPermissions => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadPermissions), + write_cap: None, + }, + SlotName::SecurityObjects => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + SlotName::SecurityData => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::Unrestricted, + read_cap: None, + write_cap: None, + }, + } +} + +// --------------------------------------------------------------------------- +// Capability Checking +// --------------------------------------------------------------------------- + +/// Check if a set of capabilities grants read access to a slot. +fn has_read_access(policy: &SlotPolicy, capabilities: &HashSet) -> bool { + if policy.access == AccessPolicy::Unrestricted { + return true; + } + if let Some(read_cap) = &policy.read_cap { + let cap_str = serde_json::to_string(read_cap) + .unwrap_or_default() + .trim_matches('"') + .to_string(); + if capabilities.contains(&cap_str) { + return true; + } + } + // Check if any subject sub-field cap implies read_subject + if policy.read_cap == Some(Capability::ReadSubject) { + return has_any_subject_capability(capabilities); + } + false +} + +/// Check if capabilities include any subject-related capability. +fn has_any_subject_capability(capabilities: &HashSet) -> bool { + let subject_caps = [ + Capability::ReadSubject, + Capability::ReadRoles, + Capability::ReadTeams, + Capability::ReadClaims, + Capability::ReadPermissions, + ]; + for cap in &subject_caps { + let cap_str = serde_json::to_string(cap) + .unwrap_or_default() + .trim_matches('"') + .to_string(); + if capabilities.contains(&cap_str) { + return true; + } + } + false +} + +/// Helper: convert Capability to its string representation. +fn cap_str(cap: Capability) -> String { + serde_json::to_string(&cap) + .unwrap_or_default() + .trim_matches('"') + .to_string() +} + +// --------------------------------------------------------------------------- +// Filter Extensions +// --------------------------------------------------------------------------- + +/// Build a Extensions containing only slots the plugin can access. +/// +/// Starts from an empty Extensions and clones in only the +/// slots the plugin has read access to. Slots not explicitly included +/// are `None`. Secure by default — if a new slot is added to +/// Extensions but not registered here, it remains hidden. +/// +/// For the security extension, filtering is granular: unrestricted +/// sub-fields (objects, data, classification) are always included, +/// while labels and subject sub-fields are gated by capabilities. +pub fn filter_extensions( + extensions: &Extensions, + capabilities: &HashSet, +) -> Extensions { + let mut filtered = Extensions::default(); + + // Unrestricted immutable — always visible + filtered.request = extensions.request.clone(); + filtered.provenance = extensions.provenance.clone(); + filtered.completion = extensions.completion.clone(); + filtered.llm = extensions.llm.clone(); + filtered.framework = extensions.framework.clone(); + filtered.mcp = extensions.mcp.clone(); + filtered.meta = extensions.meta.clone(); + filtered.custom = extensions.custom.clone(); + + // Capability-gated: delegation + if extensions.delegation.is_some() { + let policy = slot_policy(SlotName::Delegation); + if has_read_access(&policy, capabilities) { + filtered.delegation = extensions.delegation.clone(); + } + } + + // Capability-gated: agent + if extensions.agent.is_some() { + let policy = slot_policy(SlotName::Agent); + if has_read_access(&policy, capabilities) { + filtered.agent = extensions.agent.clone(); + } + } + + // Capability-gated: http + if extensions.http.is_some() { + let policy = slot_policy(SlotName::Http); + if has_read_access(&policy, capabilities) { + filtered.http = extensions.http.clone(); + } + } + + // Security — granular sub-field filtering + if let Some(ref security) = extensions.security { + filtered.security = Some(Arc::new(build_filtered_security(security, capabilities))); + } + + filtered +} + +/// Build a filtered SecurityExtension containing only accessible fields. +/// +/// Unrestricted sub-fields (objects, data, classification) are always +/// included. Labels and subject sub-fields are gated by capabilities. +fn build_filtered_security( + security: &SecurityExtension, + capabilities: &HashSet, +) -> SecurityExtension { + let mut filtered = SecurityExtension { + // Unrestricted — always included + objects: security.objects.clone(), + data: security.data.clone(), + classification: security.classification.clone(), + // Agent identity and auth method — always included (host-set, immutable) + agent: security.agent.clone(), + auth_method: security.auth_method.clone(), + // Default empty for capability-gated fields + labels: super::MonotonicSet::new(), + subject: None, + }; + + // Labels — capability-gated + let labels_policy = slot_policy(SlotName::SecurityLabels); + if has_read_access(&labels_policy, capabilities) { + filtered.labels = security.labels.clone(); + } + + // Subject — granular capability-gated + if let Some(ref subject) = security.subject { + if has_any_subject_capability(capabilities) { + filtered.subject = Some(build_filtered_subject(subject, capabilities)); + } + } + + filtered +} + +/// Build a filtered SubjectExtension containing only accessible fields. +/// +/// Always includes id and type (base subject access). Individual +/// sub-fields are only populated if the plugin holds the capability. +fn build_filtered_subject( + subject: &SubjectExtension, + capabilities: &HashSet, +) -> SubjectExtension { + SubjectExtension { + // Always included with any subject access + id: subject.id.clone(), + subject_type: subject.subject_type, + // Capability-gated sub-fields + roles: if capabilities.contains(&cap_str(Capability::ReadRoles)) { + subject.roles.clone() + } else { + HashSet::new() + }, + permissions: if capabilities.contains(&cap_str(Capability::ReadPermissions)) { + subject.permissions.clone() + } else { + HashSet::new() + }, + teams: if capabilities.contains(&cap_str(Capability::ReadTeams)) { + subject.teams.clone() + } else { + HashSet::new() + }, + claims: if capabilities.contains(&cap_str(Capability::ReadClaims)) { + subject.claims.clone() + } else { + std::collections::HashMap::new() + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extensions::SecurityExtension; + use crate::extensions::meta::MetaExtension; + + fn make_full_extensions() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.classification = Some("confidential".into()); + security.subject = Some(SubjectExtension { + id: Some("alice".into()), + subject_type: Some(super::super::security::SubjectType::User), + roles: ["admin".to_string()].into(), + permissions: ["read_all".to_string()].into(), + teams: ["engineering".to_string()].into(), + claims: [("iss".to_string(), "example.com".to_string())].into(), + }); + + let mut http = super::super::HttpExtension::default(); + http.set_header("Authorization", "Bearer token123"); + + Extensions { + request: Some(std::sync::Arc::new(super::super::RequestExtension { + request_id: Some("req-001".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(std::sync::Arc::new(http)), + agent: Some(std::sync::Arc::new(super::super::AgentExtension { + agent_id: Some("agent-1".into()), + ..Default::default() + })), + delegation: Some(std::sync::Arc::new(super::super::DelegationExtension { + delegated: true, + ..Default::default() + })), + meta: Some(std::sync::Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + custom: Some(Arc::new([("key".to_string(), serde_json::json!("value"))].into())), + ..Default::default() + } + } + + #[test] + fn test_no_capabilities_sees_unrestricted_only() { + let ext = make_full_extensions(); + let caps = HashSet::new(); + let filtered = filter_extensions(&ext, &caps); + + // Unrestricted slots visible + assert!(filtered.request.is_some()); + assert!(filtered.meta.is_some()); + assert!(filtered.custom.is_some()); + + // Capability-gated slots hidden + assert!(filtered.http.is_none()); + assert!(filtered.agent.is_none()); + assert!(filtered.delegation.is_none()); + + // Security: objects/data/classification visible, labels/subject hidden + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.labels.is_empty()); + assert!(sec.subject.is_none()); + assert_eq!(sec.classification, Some("confidential".into())); + } + + #[test] + fn test_read_headers_capability() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_headers".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + assert!(filtered.http.is_some()); + assert_eq!( + filtered.http.unwrap().get_header("Authorization"), + Some("Bearer token123") + ); + // Still no agent access + assert!(filtered.agent.is_none()); + } + + #[test] + fn test_read_agent_capability() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_agent".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + assert!(filtered.agent.is_some()); + assert_eq!( + filtered.agent.unwrap().agent_id, + Some("agent-1".into()) + ); + assert!(filtered.http.is_none()); + } + + #[test] + fn test_read_labels_capability() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_labels".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.has_label("PII")); + // No subject access — just label access + assert!(sec.subject.is_none()); + } + + #[test] + fn test_read_subject_sees_id_and_type_only() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_subject".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + let sec = filtered.security.as_ref().unwrap(); + let subject = sec.subject.as_ref().unwrap(); + assert_eq!(subject.id, Some("alice".into())); + // Sub-fields empty without specific capabilities + assert!(subject.roles.is_empty()); + assert!(subject.permissions.is_empty()); + assert!(subject.teams.is_empty()); + assert!(subject.claims.is_empty()); + } + + #[test] + fn test_read_roles_implies_subject_access() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_roles".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + let sec = filtered.security.as_ref().unwrap(); + let subject = sec.subject.as_ref().unwrap(); + // Has subject access (implied by read_roles) + assert_eq!(subject.id, Some("alice".into())); + // Has roles + assert!(subject.roles.contains("admin")); + // No other sub-fields + assert!(subject.permissions.is_empty()); + assert!(subject.teams.is_empty()); + } + + #[test] + fn test_full_capabilities() { + let ext = make_full_extensions(); + let caps: HashSet = [ + "read_headers", + "read_agent", + "read_delegation", + "read_labels", + "read_subject", + "read_roles", + "read_permissions", + "read_teams", + "read_claims", + ] + .into_iter() + .map(String::from) + .collect(); + + let filtered = filter_extensions(&ext, &caps); + + // Everything visible + assert!(filtered.http.is_some()); + assert!(filtered.agent.is_some()); + assert!(filtered.delegation.is_some()); + + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.has_label("PII")); + let subject = sec.subject.as_ref().unwrap(); + assert!(subject.roles.contains("admin")); + assert!(subject.permissions.contains("read_all")); + assert!(subject.teams.contains("engineering")); + assert!(subject.claims.contains_key("iss")); + } + + #[test] + fn test_read_delegation_capability() { + let ext = make_full_extensions(); + let caps: HashSet = ["read_delegation".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + + assert!(filtered.delegation.is_some()); + assert!(filtered.delegation.unwrap().delegated); + } +} diff --git a/crates/cpex-core/src/extensions/framework.rs b/crates/cpex-core/src/extensions/framework.rs new file mode 100644 index 00000000..b4654055 --- /dev/null +++ b/crates/cpex-core/src/extensions/framework.rs @@ -0,0 +1,38 @@ +// Location: ./crates/cpex-core/src/extensions/framework.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// FrameworkExtension — agentic framework context. +// Mirrors cpex/framework/extensions/framework.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Agentic framework context. +/// +/// Carries framework identity and graph/workflow metadata. +/// Immutable — set by the host. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FrameworkExtension { + /// Framework name (e.g., "langchain", "crewai", "autogen"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub framework: Option, + + /// Framework version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub framework_version: Option, + + /// Node ID in an agent graph/workflow. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + + /// Graph/workflow ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph_id: Option, + + /// Framework-specific metadata. + #[serde(default)] + pub metadata: HashMap, +} diff --git a/crates/cpex-core/src/extensions/guarded.rs b/crates/cpex-core/src/extensions/guarded.rs new file mode 100644 index 00000000..f317e95f --- /dev/null +++ b/crates/cpex-core/src/extensions/guarded.rs @@ -0,0 +1,141 @@ +// Location: ./crates/cpex-core/src/extensions/guarded.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Guarded — capability-gated write access. +// +// A value that requires a WriteToken for mutable access. Read access +// is always available (if the plugin can see the extension at all). +// Write access requires the framework to issue a WriteToken based on +// the plugin's declared capabilities. +// +// Mirrors the spec in rust-implementation-spec.md §2.3. + +use serde::{Deserialize, Serialize}; + +/// A value that requires a WriteToken for mutable access. +/// +/// Read access via `.read()` is always available. Write access via +/// `.write(token)` requires a `WriteToken` proving the caller has +/// the capability. +/// +/// The framework issues write tokens only to plugins that declared +/// the corresponding write capability (e.g., `write_headers`). +/// Plugin code without a token cannot call `.write()`. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Guarded { + inner: T, +} + +impl Guarded { + /// Wrap a value in a guard. + pub fn new(value: T) -> Self { + Self { inner: value } + } + + /// Read access — always available if the plugin can see this extension. + pub fn read(&self) -> &T { + &self.inner + } + + /// Write access — requires a WriteToken proving the caller has capability. + /// + /// The framework issues WriteTokens only to plugins that declared + /// the write capability in their config. Without the token, this + /// method is uncallable — the plugin can read but not write. + pub fn write(&mut self, _token: &WriteToken) -> &mut T { + &mut self.inner + } + + /// Consume the guard, returning the inner value. + pub fn into_inner(self) -> T { + self.inner + } +} + +impl Default for Guarded { + fn default() -> Self { + Self { + inner: T::default(), + } + } +} + +/// Opaque token for write access — only the framework can create one. +/// +/// `pub(crate)` constructor means plugin crates cannot mint tokens. +/// The executor creates tokens based on the plugin's declared +/// capabilities from `PluginConfig`. +pub struct WriteToken { + _private: (), +} + +impl WriteToken { + /// Only callable by the framework (pub(crate)). + /// Plugin crates cannot construct this. + pub(crate) fn new() -> Self { + Self { _private: () } + } +} + +// WriteToken is not Clone, not Copy — each plugin gets its own from the executor. +// It's also not Send/Sync by default (no auto-traits on zero-sized private fields). +// We explicitly mark it safe since it's just a capability proof with no data. +unsafe impl Send for WriteToken {} +unsafe impl Sync for WriteToken {} + +impl std::fmt::Debug for WriteToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("WriteToken") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_guarded_read_without_token() { + let guarded = Guarded::new(42); + assert_eq!(*guarded.read(), 42); + } + + #[test] + fn test_guarded_write_with_token() { + let mut guarded = Guarded::new(42); + let token = WriteToken::new(); + *guarded.write(&token) = 100; + assert_eq!(*guarded.read(), 100); + } + + #[test] + fn test_guarded_serde_transparent() { + let guarded = Guarded::new("hello".to_string()); + let json = serde_json::to_string(&guarded).unwrap(); + assert_eq!(json, "\"hello\""); + let deserialized: Guarded = serde_json::from_str(&json).unwrap(); + assert_eq!(*deserialized.read(), "hello"); + } + + #[test] + fn test_guarded_with_struct() { + use std::collections::HashMap; + + #[derive(Clone, Debug, Default, Serialize, Deserialize)] + struct Headers { + map: HashMap, + } + + let mut guarded = Guarded::new(Headers::default()); + let token = WriteToken::new(); + + // Read — no token needed + assert!(guarded.read().map.is_empty()); + + // Write — token required + guarded.write(&token).map.insert("X-Auth".into(), "Bearer tok".into()); + assert_eq!(guarded.read().map.get("X-Auth").unwrap(), "Bearer tok"); + } +} diff --git a/crates/cpex-core/src/extensions/http.rs b/crates/cpex-core/src/extensions/http.rs new file mode 100644 index 00000000..bfd52903 --- /dev/null +++ b/crates/cpex-core/src/extensions/http.rs @@ -0,0 +1,200 @@ +// Location: ./crates/cpex-core/src/extensions/http.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// HttpExtension — HTTP request and response headers. +// Mirrors cpex/framework/extensions/http.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// HTTP-related extensions. +/// +/// Carries both request and response headers separately. The host +/// populates what's available at each hook point: +/// - Pre-invoke: `request_headers` filled, `response_headers` empty +/// - Post-invoke: both filled (request from original, response from upstream) +/// +/// Capability-gated: requires `read_headers` to see, `write_headers` +/// to modify (both request and response). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HttpExtension { + /// HTTP request headers (inbound from caller). + #[serde(default)] + pub request_headers: HashMap, + + /// HTTP response headers (from upstream, populated post-invoke). + #[serde(default)] + pub response_headers: HashMap, +} + +impl HttpExtension { + // -- Request header helpers -- + + /// Set a request header (overwrites if exists). + pub fn set_request_header(&mut self, name: impl Into, value: impl Into) { + self.request_headers.insert(name.into(), value.into()); + } + + /// Get a request header value (case-insensitive lookup). + pub fn get_request_header(&self, name: &str) -> Option<&str> { + get_header_ci(&self.request_headers, name) + } + + /// Check if a request header exists (case-insensitive). + pub fn has_request_header(&self, name: &str) -> bool { + self.get_request_header(name).is_some() + } + + /// Add request header only if it doesn't exist. Returns true if added. + pub fn add_request_header(&mut self, name: impl Into, value: impl Into) -> bool { + let name = name.into(); + if self.has_request_header(&name) { + return false; + } + self.request_headers.insert(name, value.into()); + true + } + + /// Remove a request header by name. Returns the removed value. + pub fn remove_request_header(&mut self, name: &str) -> Option { + remove_header_ci(&mut self.request_headers, name) + } + + // -- Response header helpers -- + + /// Set a response header (overwrites if exists). + pub fn set_response_header(&mut self, name: impl Into, value: impl Into) { + self.response_headers.insert(name.into(), value.into()); + } + + /// Get a response header value (case-insensitive lookup). + pub fn get_response_header(&self, name: &str) -> Option<&str> { + get_header_ci(&self.response_headers, name) + } + + /// Check if a response header exists (case-insensitive). + pub fn has_response_header(&self, name: &str) -> bool { + self.get_response_header(name).is_some() + } + + // -- Convenience aliases (backward-compatible, default to request) -- + + /// Set a header on request headers (convenience alias). + pub fn set_header(&mut self, name: impl Into, value: impl Into) { + self.set_request_header(name, value); + } + + /// Get a header from request headers (convenience alias, case-insensitive). + pub fn get_header(&self, name: &str) -> Option<&str> { + self.get_request_header(name) + } + + /// Check if a request header exists (convenience alias). + pub fn has_header(&self, name: &str) -> bool { + self.has_request_header(name) + } +} + +// -- Internal helpers -- + +fn get_header_ci<'a>(headers: &'a HashMap, name: &str) -> Option<&'a str> { + let lower = name.to_lowercase(); + headers + .iter() + .find(|(k, _)| k.to_lowercase() == lower) + .map(|(_, v)| v.as_str()) +} + +fn remove_header_ci(headers: &mut HashMap, name: &str) -> Option { + let lower = name.to_lowercase(); + let key = headers + .keys() + .find(|k| k.to_lowercase() == lower) + .cloned(); + key.and_then(|k| headers.remove(&k)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_request_header_set_and_get() { + let mut http = HttpExtension::default(); + http.set_request_header("Content-Type", "application/json"); + assert_eq!(http.get_request_header("Content-Type"), Some("application/json")); + } + + #[test] + fn test_request_header_case_insensitive() { + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer tok"); + assert_eq!(http.get_request_header("authorization"), Some("Bearer tok")); + assert_eq!(http.get_request_header("AUTHORIZATION"), Some("Bearer tok")); + } + + #[test] + fn test_response_header_set_and_get() { + let mut http = HttpExtension::default(); + http.set_response_header("Content-Type", "text/html"); + assert_eq!(http.get_response_header("Content-Type"), Some("text/html")); + assert!(http.has_response_header("content-type")); + } + + #[test] + fn test_request_and_response_independent() { + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer req-tok"); + http.set_response_header("X-Response-Time", "42ms"); + + // Request headers don't leak into response + assert!(http.get_response_header("Authorization").is_none()); + // Response headers don't leak into request + assert!(http.get_request_header("X-Response-Time").is_none()); + } + + #[test] + fn test_convenience_aliases_default_to_request() { + let mut http = HttpExtension::default(); + http.set_header("X-Custom", "value"); + assert_eq!(http.get_header("X-Custom"), Some("value")); + assert!(http.has_header("X-Custom")); + // Verify it went to request_headers + assert_eq!(http.get_request_header("X-Custom"), Some("value")); + } + + #[test] + fn test_add_request_header_only_if_absent() { + let mut http = HttpExtension::default(); + assert!(http.add_request_header("X-New", "first")); + assert!(!http.add_request_header("X-New", "second")); + assert_eq!(http.get_request_header("X-New"), Some("first")); + } + + #[test] + fn test_remove_request_header() { + let mut http = HttpExtension::default(); + http.set_request_header("X-Remove", "value"); + let removed = http.remove_request_header("x-remove"); + assert_eq!(removed, Some("value".to_string())); + assert!(!http.has_request_header("X-Remove")); + } + + #[test] + fn test_serde_roundtrip() { + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer tok"); + http.set_request_header("X-Request-ID", "req-123"); + http.set_response_header("Content-Type", "application/json"); + http.set_response_header("X-Response-Time", "15ms"); + + let json = serde_json::to_string(&http).unwrap(); + let deserialized: HttpExtension = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.get_request_header("Authorization"), Some("Bearer tok")); + assert_eq!(deserialized.get_response_header("Content-Type"), Some("application/json")); + } +} diff --git a/crates/cpex-core/src/extensions/llm.rs b/crates/cpex-core/src/extensions/llm.rs new file mode 100644 index 00000000..adc2225c --- /dev/null +++ b/crates/cpex-core/src/extensions/llm.rs @@ -0,0 +1,27 @@ +// Location: ./crates/cpex-core/src/extensions/llm.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// LLMExtension — model identity and capabilities. +// Mirrors cpex/framework/extensions/llm.py. + +use serde::{Deserialize, Serialize}; + +/// Model identity and capabilities. +/// +/// Immutable — set by the host. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LLMExtension { + /// Model identifier (e.g., "gpt-4o", "claude-sonnet-4-20250514"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + + /// Provider name (e.g., "openai", "anthropic"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + + /// Model capabilities (e.g., "tool_use", "vision", "streaming"). + #[serde(default)] + pub capabilities: Vec, +} diff --git a/crates/cpex-core/src/extensions/mcp.rs b/crates/cpex-core/src/extensions/mcp.rs new file mode 100644 index 00000000..c5eed384 --- /dev/null +++ b/crates/cpex-core/src/extensions/mcp.rs @@ -0,0 +1,115 @@ +// Location: ./crates/cpex-core/src/extensions/mcp.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MCPExtension — tool, resource, or prompt metadata. +// Mirrors cpex/framework/extensions/mcp.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// MCP tool metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ToolMetadata { + /// Tool name. + pub name: String, + + /// Human-readable title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + + /// Tool description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Input JSON schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + + /// Output JSON schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + + /// Source server ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, + + /// Tool namespace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + + /// Tool annotations. + #[serde(default)] + pub annotations: HashMap, +} + +/// MCP resource metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ResourceMetadata { + /// Resource URI. + pub uri: String, + + /// Human-readable name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Resource description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// MIME type. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + + /// Source server ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, + + /// Resource annotations. + #[serde(default)] + pub annotations: HashMap, +} + +/// MCP prompt metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PromptMetadata { + /// Prompt name. + pub name: String, + + /// Prompt description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Prompt arguments schema. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arguments: Option>, + + /// Source server ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_id: Option, + + /// Prompt annotations. + #[serde(default)] + pub annotations: HashMap, +} + +/// MCP-specific metadata extension. +/// +/// Carries tool, resource, or prompt metadata for the entity +/// being processed. Immutable — set by the host. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MCPExtension { + /// Tool metadata (if this message involves a tool). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool: Option, + + /// Resource metadata (if this message involves a resource). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + + /// Prompt metadata (if this message involves a prompt). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, +} diff --git a/crates/cpex-core/src/extensions/meta.rs b/crates/cpex-core/src/extensions/meta.rs new file mode 100644 index 00000000..4ba55516 --- /dev/null +++ b/crates/cpex-core/src/extensions/meta.rs @@ -0,0 +1,45 @@ +// Location: ./crates/cpex-core/src/extensions/meta.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MetaExtension — host-provided operational metadata. +// Mirrors cpex/framework/extensions/meta.py. + +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +/// Host-provided operational metadata. +/// +/// Carries entity identification (type + name) for route resolution, +/// operational tags for policy group inheritance, scope for +/// host-defined grouping, and arbitrary properties. +/// +/// Immutable — set by the host before invoking the hook. Plugins +/// can read but not modify. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MetaExtension { + /// Entity type: "tool", "resource", "prompt", "llm". + /// Used by the manager for route resolution. + #[serde(default)] + pub entity_type: Option, + + /// Entity name: "get_compensation", "hr://employees/*", etc. + /// Used by the manager for route resolution. + #[serde(default)] + pub entity_name: Option, + + /// Operational tags — drive policy group inheritance. + /// Merged with static tags from the matching route's `meta.tags`. + #[serde(default)] + pub tags: HashSet, + + /// Host-defined grouping (virtual server ID, namespace, etc.). + #[serde(default)] + pub scope: Option, + + /// Arbitrary key-value metadata. + #[serde(default)] + pub properties: HashMap, +} diff --git a/crates/cpex-core/src/extensions/mod.rs b/crates/cpex-core/src/extensions/mod.rs new file mode 100644 index 00000000..43235833 --- /dev/null +++ b/crates/cpex-core/src/extensions/mod.rs @@ -0,0 +1,52 @@ +// Location: ./crates/cpex-core/src/extensions/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed extension models for the CPEX framework. +// +// Each extension carries contextual metadata with an explicit +// mutability tier enforced by the processing pipeline. Extensions +// are always passed separately from the payload to handlers. +// +// Mirrors the Python extensions in cpex/framework/extensions/. + +pub mod agent; +pub mod completion; +pub mod container; +pub mod delegation; +pub mod filter; +pub mod framework; +pub mod guarded; +pub mod http; +pub mod llm; +pub mod mcp; +pub mod meta; +pub mod monotonic; +pub mod provenance; +pub mod request; +pub mod security; +pub mod tiers; + +// Re-export containers +pub use container::{Extensions, OwnedExtensions}; + +// Re-export all extension types +pub use agent::{AgentExtension, ConversationContext}; +pub use completion::{CompletionExtension, StopReason, TokenUsage}; +pub use delegation::{DelegationExtension, DelegationHop}; +pub use framework::FrameworkExtension; +pub use guarded::{Guarded, WriteToken}; +pub use http::HttpExtension; +pub use llm::LLMExtension; +pub use mcp::{MCPExtension, PromptMetadata, ResourceMetadata, ToolMetadata}; +pub use meta::MetaExtension; +pub use monotonic::{DeclassifierToken, MonotonicSet}; +pub use provenance::ProvenanceExtension; +pub use request::RequestExtension; +pub use security::{ + AgentIdentity, DataPolicy, ObjectSecurityProfile, RetentionPolicy, SecurityExtension, + SubjectExtension, SubjectType, +}; +pub use filter::{filter_extensions, SlotName}; +pub use tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; diff --git a/crates/cpex-core/src/extensions/monotonic.rs b/crates/cpex-core/src/extensions/monotonic.rs new file mode 100644 index 00000000..65c004c2 --- /dev/null +++ b/crates/cpex-core/src/extensions/monotonic.rs @@ -0,0 +1,183 @@ +// Location: ./crates/cpex-core/src/extensions/monotonic.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MonotonicSet — add-only set enforced at the type level. +// +// Security labels can only grow. The type exposes insert() but not +// remove(). Declassification requires a DeclassifierToken that only +// the security subsystem can construct. +// +// Mirrors the spec in rust-implementation-spec.md §2.2. + +use std::collections::HashSet; +use std::hash::Hash; + +use serde::{Deserialize, Serialize}; + +/// A set that only allows additions. No remove() in the public API. +/// +/// Plugins can call `insert()` but not `remove()`. Declassification +/// (removal) requires a `DeclassifierToken` that only the security +/// subsystem can construct. +/// +/// This enforces the monotonic tier at compile time — a plugin that +/// tries to call `.remove()` gets a compile error. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MonotonicSet { + inner: HashSet, +} + +impl MonotonicSet { + /// Create an empty monotonic set. + pub fn new() -> Self { + Self { + inner: HashSet::new(), + } + } + + /// Create from an existing HashSet. + pub fn from_set(set: HashSet) -> Self { + Self { inner: set } + } + + /// Add a value. Returns true if the value was newly inserted. + pub fn insert(&mut self, value: T) -> bool { + self.inner.insert(value) + } + + /// Check if the set contains a value. + pub fn contains(&self, value: &T) -> bool { + self.inner.contains(value) + } + + /// Iterate over the values. + pub fn iter(&self) -> impl Iterator { + self.inner.iter() + } + + /// Number of elements. + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Whether the set is empty. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Whether this set is a superset of another. + pub fn is_superset(&self, other: &MonotonicSet) -> bool { + self.inner.is_superset(&other.inner) + } + + /// Get a reference to the inner HashSet (read-only). + pub fn as_set(&self) -> &HashSet { + &self.inner + } + + /// Removal requires a DeclassifierToken — privileged, audited operation. + /// Only the security subsystem can construct the token. + pub fn remove_with_declassifier( + &mut self, + value: &T, + _token: &DeclassifierToken, + ) -> bool { + self.inner.remove(value) + } +} + +impl Default for MonotonicSet { + fn default() -> Self { + Self::new() + } +} + +/// Opaque token for declassification — only the security subsystem +/// can create one. Constructing this token is a privileged operation. +pub struct DeclassifierToken { + _private: (), +} + +impl DeclassifierToken { + /// Only callable by the framework/security subsystem. + #[allow(dead_code)] + pub(crate) fn new() -> Self { + Self { _private: () } + } +} + +/// Case-insensitive label lookup on MonotonicSet. +impl MonotonicSet { + /// Check if a label exists (case-insensitive). + pub fn has_label(&self, label: &str) -> bool { + let lower = label.to_lowercase(); + self.inner.iter().any(|l| l.to_lowercase() == lower) + } + + /// Add a label (case-preserving on insert). + pub fn add_label(&mut self, label: impl Into) { + self.inner.insert(label.into()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_monotonic_insert_only() { + let mut set = MonotonicSet::new(); + set.insert("PII".to_string()); + set.insert("CONFIDENTIAL".to_string()); + assert!(set.contains(&"PII".to_string())); + assert_eq!(set.len(), 2); + // No remove() method available — this is the key guarantee + } + + #[test] + fn test_monotonic_superset() { + let mut before = MonotonicSet::new(); + before.insert("PII".to_string()); + + let mut after = before.clone(); + after.insert("HIPAA".to_string()); + + assert!(after.is_superset(&before)); + assert!(!before.is_superset(&after)); + } + + #[test] + fn test_monotonic_declassifier() { + let mut set = MonotonicSet::new(); + set.insert("PII".to_string()); + + // Only works with the token + let token = DeclassifierToken::new(); + assert!(set.remove_with_declassifier(&"PII".to_string(), &token)); + assert!(!set.contains(&"PII".to_string())); + } + + #[test] + fn test_monotonic_has_label_case_insensitive() { + let mut set = MonotonicSet::new(); + set.add_label("PII"); + assert!(set.has_label("pii")); + assert!(set.has_label("PII")); + assert!(set.has_label("Pii")); + } + + #[test] + fn test_monotonic_serde_roundtrip() { + let mut set = MonotonicSet::new(); + set.insert("PII".to_string()); + set.insert("HIPAA".to_string()); + + let json = serde_json::to_string(&set).unwrap(); + let deserialized: MonotonicSet = serde_json::from_str(&json).unwrap(); + assert!(deserialized.contains(&"PII".to_string())); + assert!(deserialized.contains(&"HIPAA".to_string())); + } +} diff --git a/crates/cpex-core/src/extensions/provenance.rs b/crates/cpex-core/src/extensions/provenance.rs new file mode 100644 index 00000000..1873f521 --- /dev/null +++ b/crates/cpex-core/src/extensions/provenance.rs @@ -0,0 +1,27 @@ +// Location: ./crates/cpex-core/src/extensions/provenance.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// ProvenanceExtension — origin and message threading. +// Mirrors cpex/framework/extensions/provenance.py. + +use serde::{Deserialize, Serialize}; + +/// Origin and message threading. +/// +/// Immutable — set by the host. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProvenanceExtension { + /// Source system or service. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + + /// Unique message identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, + + /// Parent message ID (for threading). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, +} diff --git a/crates/cpex-core/src/extensions/request.rs b/crates/cpex-core/src/extensions/request.rs new file mode 100644 index 00000000..435ab1fa --- /dev/null +++ b/crates/cpex-core/src/extensions/request.rs @@ -0,0 +1,35 @@ +// Location: ./crates/cpex-core/src/extensions/request.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// RequestExtension — execution environment and tracing. +// Mirrors cpex/framework/extensions/request.py. + +use serde::{Deserialize, Serialize}; + +/// Execution environment and request tracing. +/// +/// Immutable — set by the host before invoking the hook. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RequestExtension { + /// Deployment environment (e.g., "production", "staging"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment: Option, + + /// Unique request identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + + /// Request timestamp (ISO 8601). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + + /// Distributed trace ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace_id: Option, + + /// Span ID within the trace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub span_id: Option, +} diff --git a/crates/cpex-core/src/extensions/security.rs b/crates/cpex-core/src/extensions/security.rs new file mode 100644 index 00000000..717baa72 --- /dev/null +++ b/crates/cpex-core/src/extensions/security.rs @@ -0,0 +1,337 @@ +// Location: ./crates/cpex-core/src/extensions/security.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// SecurityExtension — labels, classification, identity, data policy. +// Mirrors cpex/framework/extensions/security.py. + +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use super::monotonic::MonotonicSet; + +/// Subject type for identity classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubjectType { + User, + Agent, + Service, + System, +} + +/// Authenticated subject identity. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SubjectExtension { + /// Subject identifier (e.g., JWT sub). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Subject type. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_type: Option, + + /// Assigned roles. + #[serde(default)] + pub roles: HashSet, + + /// Granted permissions. + #[serde(default)] + pub permissions: HashSet, + + /// Team memberships. + #[serde(default)] + pub teams: HashSet, + + /// Raw claims (e.g., JWT claims). + #[serde(default)] + pub claims: HashMap, +} + +/// Security profile for a managed object. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ObjectSecurityProfile { + /// Who manages this object. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub managed_by: Option, + + /// Required permissions. + #[serde(default)] + pub permissions: Vec, + + /// Trust domain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trust_domain: Option, + + /// Data scope. + #[serde(default)] + pub data_scope: Vec, +} + +/// Retention policy for data. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RetentionPolicy { + /// Maximum age in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_age_seconds: Option, + + /// Policy name. + #[serde(default)] + pub policy: String, + + /// Deletion timestamp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delete_after: Option, +} + +/// Data policy for a named data element. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DataPolicy { + /// Labels to apply. + #[serde(default)] + pub apply_labels: Vec, + + /// Allowed actions (None = all allowed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_actions: Option>, + + /// Denied actions. + #[serde(default)] + pub denied_actions: Vec, + + /// Retention policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retention: Option, +} + +/// This agent's own workload identity. +/// +/// Distinct from `SubjectExtension` which represents the *caller*. +/// `AgentIdentity` represents *this agent/service* — its own +/// workload identity, OAuth client_id, and trust domain. +/// +/// Populated by the host before the pipeline runs. Plugins can +/// make decisions based on both who is calling (Subject) and +/// which agent is processing (AgentIdentity). +/// +/// Maps to AuthBridge's `AgentIdentity` and the Go bindings' +/// `SecurityExtension.Agent`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AgentIdentity { + /// OAuth client_id of this agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + + /// Workload identity URI (SPIFFE, k8s service account, platform-specific). + /// e.g., `spiffe://example.com/ns/team1/sa/weather-tool` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workload_id: Option, + + /// Trust domain of the workload identity. + /// e.g., `example.com` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trust_domain: Option, +} + +/// Security-related extensions. +/// +/// Carries security labels (monotonic add-only), classification, +/// authenticated caller identity (subject), this agent's own +/// workload identity (agent), object security profiles, and +/// data policies. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SecurityExtension { + /// Security labels (monotonic — add-only via MonotonicSet). + /// No remove() method — enforced at compile time. + #[serde(default)] + pub labels: MonotonicSet, + + /// Data classification level. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub classification: Option, + + /// Authenticated caller identity (who is calling). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + + /// This agent's own workload identity (who this agent is). + /// Populated by the host, not by plugins. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + + /// Authentication method used (e.g., "jwt", "mtls", "spiffe", "api_key"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_method: Option, + + /// Object security profiles keyed by object name. + #[serde(default)] + pub objects: HashMap, + + /// Data policies keyed by data element name. + #[serde(default)] + pub data: HashMap, +} + +impl SecurityExtension { + /// Add a security label (monotonic — cannot remove). + pub fn add_label(&mut self, label: impl Into) { + self.labels.add_label(label); + } + + /// Check if a label exists (case-insensitive). + pub fn has_label(&self, label: &str) -> bool { + self.labels.has_label(label) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_security_labels_monotonic() { + let mut sec = SecurityExtension::default(); + sec.add_label("PII"); + sec.add_label("HIPAA"); + assert!(sec.has_label("PII")); + assert!(sec.has_label("pii")); // case-insensitive + assert!(sec.has_label("HIPAA")); + assert!(!sec.has_label("SOX")); + } + + #[test] + fn test_security_classification() { + let mut sec = SecurityExtension::default(); + sec.classification = Some("confidential".into()); + assert_eq!(sec.classification.as_deref(), Some("confidential")); + } + + #[test] + fn test_subject_extension() { + let subject = SubjectExtension { + id: Some("alice".into()), + subject_type: Some(SubjectType::User), + roles: ["admin".to_string(), "hr".to_string()].into(), + permissions: ["read_all".to_string()].into(), + teams: ["engineering".to_string()].into(), + claims: [("iss".to_string(), "auth.example.com".to_string())].into(), + }; + assert_eq!(subject.id.as_deref(), Some("alice")); + assert_eq!(subject.subject_type, Some(SubjectType::User)); + assert!(subject.roles.contains("admin")); + assert!(subject.permissions.contains("read_all")); + assert!(subject.teams.contains("engineering")); + assert_eq!(subject.claims.get("iss").unwrap(), "auth.example.com"); + } + + #[test] + fn test_agent_identity() { + let agent = AgentIdentity { + client_id: Some("weather-agent".into()), + workload_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()), + trust_domain: Some("example.com".into()), + }; + assert_eq!(agent.client_id.as_deref(), Some("weather-agent")); + assert_eq!( + agent.workload_id.as_deref(), + Some("spiffe://example.com/ns/team1/sa/weather-tool") + ); + assert_eq!(agent.trust_domain.as_deref(), Some("example.com")); + } + + #[test] + fn test_agent_identity_default() { + let agent = AgentIdentity::default(); + assert!(agent.client_id.is_none()); + assert!(agent.workload_id.is_none()); + assert!(agent.trust_domain.is_none()); + } + + #[test] + fn test_security_with_agent_and_subject() { + let sec = SecurityExtension { + labels: { + let mut l = super::super::MonotonicSet::new(); + l.add_label("PII"); + l + }, + classification: Some("confidential".into()), + subject: Some(SubjectExtension { + id: Some("alice".into()), + subject_type: Some(SubjectType::User), + ..Default::default() + }), + agent: Some(AgentIdentity { + client_id: Some("hr-agent".into()), + workload_id: Some("spiffe://corp.com/hr-agent".into()), + trust_domain: Some("corp.com".into()), + }), + auth_method: Some("jwt".into()), + ..Default::default() + }; + + // Caller identity + assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice")); + // Agent identity (distinct from caller) + assert_eq!(sec.agent.as_ref().unwrap().client_id.as_deref(), Some("hr-agent")); + assert_eq!(sec.agent.as_ref().unwrap().trust_domain.as_deref(), Some("corp.com")); + // Auth method + assert_eq!(sec.auth_method.as_deref(), Some("jwt")); + // Labels + assert!(sec.has_label("PII")); + } + + #[test] + fn test_security_serde_roundtrip() { + let mut sec = SecurityExtension::default(); + sec.add_label("PII"); + sec.classification = Some("internal".into()); + sec.agent = Some(AgentIdentity { + client_id: Some("my-agent".into()), + ..Default::default() + }); + sec.auth_method = Some("mtls".into()); + + let json = serde_json::to_string(&sec).unwrap(); + let deserialized: SecurityExtension = serde_json::from_str(&json).unwrap(); + + assert!(deserialized.has_label("PII")); + assert_eq!(deserialized.classification.as_deref(), Some("internal")); + assert_eq!( + deserialized.agent.as_ref().unwrap().client_id.as_deref(), + Some("my-agent") + ); + assert_eq!(deserialized.auth_method.as_deref(), Some("mtls")); + } + + #[test] + fn test_object_security_profile() { + let profile = ObjectSecurityProfile { + managed_by: Some("hr-system".into()), + permissions: vec!["read".into(), "write".into()], + trust_domain: Some("corp.com".into()), + data_scope: vec!["employee_data".into()], + }; + assert_eq!(profile.managed_by.as_deref(), Some("hr-system")); + assert_eq!(profile.permissions.len(), 2); + } + + #[test] + fn test_data_policy() { + let policy = DataPolicy { + apply_labels: vec!["PII".into()], + allowed_actions: Some(vec!["read".into()]), + denied_actions: vec!["delete".into()], + retention: Some(RetentionPolicy { + max_age_seconds: Some(86400), + policy: "30-day".into(), + delete_after: Some("2026-05-01".into()), + }), + }; + assert_eq!(policy.apply_labels[0], "PII"); + assert!(policy.retention.is_some()); + assert_eq!(policy.retention.as_ref().unwrap().max_age_seconds, Some(86400)); + } +} diff --git a/crates/cpex-core/src/extensions/tiers.rs b/crates/cpex-core/src/extensions/tiers.rs new file mode 100644 index 00000000..a22406f9 --- /dev/null +++ b/crates/cpex-core/src/extensions/tiers.rs @@ -0,0 +1,100 @@ +// Location: ./crates/cpex-core/src/extensions/tiers.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Mutability tiers and capability definitions. +// +// Each extension slot has a mutability tier that controls how plugins +// can interact with it. Capabilities gate per-plugin access. +// +// Mirrors cpex/framework/extensions/tiers.py. + +use serde::{Deserialize, Serialize}; + +/// Mutability tier for an extension slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MutabilityTier { + /// Cannot be modified after creation. + Immutable, + /// Can only grow (add-only sets, append-only chains). + Monotonic, + /// Can be freely modified by plugins with write capability. + Mutable, +} + +/// Declared permission that controls extension access. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + /// Read the authenticated subject identity. + ReadSubject, + /// Read subject roles. + ReadRoles, + /// Read subject team memberships. + ReadTeams, + /// Read subject claims (e.g., JWT claims). + ReadClaims, + /// Read subject permissions. + ReadPermissions, + /// Read the agent execution context. + ReadAgent, + /// Read HTTP headers. + ReadHeaders, + /// Write (modify) HTTP headers. + WriteHeaders, + /// Read security labels. + ReadLabels, + /// Append security labels (monotonic add-only). + AppendLabels, + /// Read the delegation chain. + ReadDelegation, + /// Append to the delegation chain (monotonic). + AppendDelegation, +} + +/// Access policy for an extension slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccessPolicy { + /// All plugins can access. + Unrestricted, + /// Only plugins with the declared capability can access. + CapabilityGated, +} + +/// Policy for a single extension slot. +/// +/// Declares the mutability tier, access policy, and required +/// capabilities for reading and writing. +#[derive(Debug, Clone)] +pub struct SlotPolicy { + /// How the slot can be modified. + pub tier: MutabilityTier, + /// Whether access requires a capability. + pub access: AccessPolicy, + /// Capability required for reading (if capability-gated). + pub read_cap: Option, + /// Capability required for writing (if capability-gated). + pub write_cap: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tier_serde() { + let tier = MutabilityTier::Monotonic; + let json = serde_json::to_string(&tier).unwrap(); + assert_eq!(json, "\"monotonic\""); + } + + #[test] + fn test_capability_serde() { + let cap = Capability::AppendLabels; + let json = serde_json::to_string(&cap).unwrap(); + assert_eq!(json, "\"append_labels\""); + } +} diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs index e0339b95..d60b0376 100644 --- a/crates/cpex-core/src/hooks/adapter.rs +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use crate::context::PluginContext; use crate::error::PluginError; use crate::executor::erase_result; -use crate::hooks::payload::{FilteredExtensions, PluginPayload}; +use crate::hooks::payload::{Extensions, PluginPayload}; use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; use crate::plugin::Plugin; use crate::registry::AnyHookHandler; @@ -82,7 +82,7 @@ where async fn invoke( &self, payload: &dyn PluginPayload, - extensions: &FilteredExtensions, + extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, PluginError> { let typed_ref: &H::Payload = payload diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs index 7f4d6ce4..e7fb48f3 100644 --- a/crates/cpex-core/src/hooks/mod.rs +++ b/crates/cpex-core/src/hooks/mod.rs @@ -10,7 +10,7 @@ // - [`HookTypeDef`] — marker trait associating a typed payload + result with a hook name. // - [`PluginPayload`] — base trait for all hook payloads (mirrors Python's PluginPayload). // - [`PluginResult`] — result type with separate payload and extension modifications. -// - [`FilteredExtensions`] — capability-gated extension view passed to handlers. +// - [`Extensions`] — capability-gated extension view passed to handlers. // - [`define_hook!`] — macro for declaring new hook types with handler traits. // - [`hook_names`] / [`cmf_hook_names`] — string constants for built-in hooks. // @@ -24,6 +24,6 @@ pub mod types; // Re-export core types at the hooks level pub use adapter::TypedHandlerAdapter; -pub use payload::{Extensions, FilteredExtensions, PluginPayload}; +pub use payload::{Extensions, PluginPayload}; pub use trait_def::{HookHandler, HookTypeDef, PluginResult}; pub use types::{builtin_hook_types, hook_type_from_str, HookType}; diff --git a/crates/cpex-core/src/hooks/payload.rs b/crates/cpex-core/src/hooks/payload.rs index f46d89c6..2a9b2949 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -21,98 +21,15 @@ // modification without copying the payload. use std::any::Any; -use std::collections::HashMap; use std::fmt; -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// Extensions (stub — fleshed out in Phase 3 with full CMF types) -// --------------------------------------------------------------------------- - -/// Typed container for all message extensions. -/// -/// Each field corresponds to an extension with an explicit mutability -/// tier enforced by the processing pipeline. Extensions are always -/// passed separately from the payload to handlers. -/// -/// This is a Phase 1 stub with minimal fields. Phase 3 adds the -/// full CMF extension types (SecurityExtension with MonotonicSet, -/// DelegationExtension with scope-narrowing chain, HttpExtension -/// with Guarded, etc.). -/// -/// Mirrors Python's `cpex.framework.extensions.Extensions`. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Extensions { - /// Host-provided operational metadata — entity identification, - /// tags, scope, and arbitrary properties. Immutable. - #[serde(default)] - pub meta: Option, - - /// Security labels (monotonic — add-only in the full implementation). - #[serde(default)] - pub labels: std::collections::HashSet, - - /// Custom extensions (mutable — no restrictions). - #[serde(default)] - pub custom: HashMap, -} - -/// Host-provided operational metadata about the entity being processed. -/// -/// Carries entity identification (type + name) for route resolution, -/// operational tags for policy group inheritance, scope for host-defined -/// grouping, and arbitrary properties for policy conditions. -/// -/// Immutable — set by the host before invoking the hook. Plugins -/// can read but not modify. -/// -/// Mirrors Python's `cpex.framework.extensions.meta.MetaExtension`. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct MetaExtension { - /// Entity type: "tool", "resource", "prompt", "llm". - /// Used by the manager for route resolution. - #[serde(default)] - pub entity_type: Option, - - /// Entity name: "get_compensation", "hr://employees/*", etc. - /// Used by the manager for route resolution. - #[serde(default)] - pub entity_name: Option, - - /// Operational tags — drive policy group inheritance. - /// Merged with static tags from the matching route's `meta.tags`. - #[serde(default)] - pub tags: std::collections::HashSet, - - /// Host-defined grouping (virtual server ID, namespace, etc.). - #[serde(default)] - pub scope: Option, - - /// Arbitrary key-value metadata. - #[serde(default)] - pub properties: HashMap, -} - -/// Capability-filtered view of Extensions for a specific plugin. -/// -/// Built by the framework before dispatching to each plugin. Fields -/// the plugin hasn't declared capabilities for are `None`. Plugins -/// receive this as a separate parameter — never inside the payload. -/// -/// Phase 1 stub — Phase 3 adds per-field capability gating matching -/// the Python `filter_extensions()` implementation. -#[derive(Debug, Clone, Default)] -pub struct FilteredExtensions { - /// Meta extension (always visible — immutable, no capability needed). - pub meta: Option, - - /// Security labels (visible with `read_labels` capability). - pub labels: Option>, - - /// Custom extensions (always visible). - pub custom: Option>, -} +// Re-export Extensions and OwnedExtensions from the extensions module. +// These are the typed containers for all extension data. They live in +// extensions/container.rs but are re-exported here for backward +// compatibility with existing code that imports from hooks::payload. +pub use crate::extensions::{ + Extensions, Guarded, MetaExtension, OwnedExtensions, WriteToken, +}; // --------------------------------------------------------------------------- // PluginPayload Trait @@ -139,7 +56,7 @@ pub struct FilteredExtensions { /// - `'static` — payloads must be owned types (no borrowed references). /// /// Extensions are **not** part of the payload. They are passed as a -/// separate `&FilteredExtensions` parameter to handlers. +/// separate `&Extensions` parameter to handlers. /// /// # Examples /// @@ -216,3 +133,4 @@ macro_rules! impl_plugin_payload { } }; } + diff --git a/crates/cpex-core/src/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs index a437c955..e07c7ab0 100644 --- a/crates/cpex-core/src/hooks/trait_def.rs +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -21,7 +21,7 @@ use crate::context::PluginContext; use crate::error::PluginViolation; -use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; +use crate::hooks::payload::{Extensions, PluginPayload}; use crate::plugin::Plugin; // --------------------------------------------------------------------------- @@ -90,7 +90,7 @@ pub trait HookTypeDef: Send + Sync + 'static { /// fn handle( /// &self, /// payload: MessagePayload, -/// extensions: &FilteredExtensions, +/// extensions: &Extensions, /// ctx: &PluginContext, /// ) -> PluginResult { /// PluginResult::allow() @@ -115,7 +115,7 @@ pub trait HookHandler: Plugin + Send + Sync { fn handle( &self, payload: &H::Payload, - extensions: &FilteredExtensions, + extensions: &Extensions, ctx: &mut PluginContext, ) -> H::Result; } @@ -163,7 +163,7 @@ pub trait HookHandler: Plugin + Send + Sync { /// assert!(!result.continue_processing); /// assert!(result.violation.is_some()); /// ``` -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct PluginResult { /// Whether the pipeline should continue processing. /// `false` halts the pipeline (deny). Only respected for @@ -175,10 +175,10 @@ pub struct PluginResult { pub modified_payload: Option

, /// Modified extensions. `None` means no extension changes. - /// Merged back by the framework using tier validation - /// (immutable rejected, monotonic superset-checked, etc.). - /// Only accepted from Sequential and Transform mode plugins. - pub modified_extensions: Option, + /// Return an `OwnedExtensions` from `extensions.cow_copy()`. + /// The executor validates (immutable unchanged, monotonic superset) + /// and merges back into the pipeline's `Extensions`. + pub modified_extensions: Option, /// Policy violation. Present when `continue_processing` is `false`. pub violation: Option, @@ -226,7 +226,8 @@ impl PluginResult

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

{ } /// Modify both payload and extensions. - pub fn modify(payload: P, extensions: Extensions) -> Self { + /// Takes an `OwnedExtensions` from `extensions.cow_copy()`. + pub fn modify(payload: P, extensions: crate::hooks::payload::OwnedExtensions) -> Self { Self { continue_processing: true, modified_payload: Some(payload), diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index fbede921..c95aa3e7 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -19,9 +19,12 @@ // - [`config`] — Unified YAML configuration parsing // - [`factory`] — Plugin factory registry for config-driven instantiation // - [`context`] — PluginContext (local_state + global_state) +// - [`cmf`] — ContextForge Message Format (Message, ContentPart, enums) // - [`error`] — Error types, violations, and result types +pub mod cmf; pub mod config; +pub mod extensions; pub mod context; pub mod error; pub mod executor; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 0810bbba..e72d17c7 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -627,6 +627,75 @@ impl PluginManager { .await } + /// Invoke a typed hook by explicit name. + /// + /// Combines compile-time payload type checking (from `H`) with + /// runtime hook name routing (from `hook_name`). Use this when + /// a single hook type (e.g., `CmfHook`) covers multiple hook + /// names (e.g., `cmf.tool_pre_invoke`, `cmf.tool_post_invoke`). + /// + /// # Type Parameters + /// + /// - `H` — the hook type (provides payload type checking). + /// + /// # Arguments + /// + /// * `hook_name` — the hook name for dispatch routing. + /// * `payload` — the typed payload (compile-time checked against `H::Payload`). + /// * `extensions` — the full extensions. + /// * `context_table` — optional context table from a previous hook. + /// + /// # Examples + /// + /// ```rust,ignore + /// // Compile-time: payload must be MessagePayload (from CmfHook) + /// // Runtime: dispatches to plugins registered under "cmf.tool_pre_invoke" + /// let (result, bg) = mgr.invoke_named::( + /// "cmf.tool_pre_invoke", payload, ext, None, + /// ).await; + /// ``` + pub async fn invoke_named( + &self, + hook_name: &str, + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks) { + let hook_type = HookType::new(hook_name); + let all_entries = self.registry.entries_for_hook(&hook_type); + + if all_entries.is_empty() { + let boxed: Box = Box::new(payload); + return ( + PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), + ); + } + + let entries = self.filter_entries_by_route(all_entries, &extensions, hook_name); + + if entries.is_empty() { + let boxed: Box = Box::new(payload); + return ( + PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ), + BackgroundTasks::empty(), + ); + } + + let boxed: Box = Box::new(payload); + self.executor + .execute(&entries, boxed, extensions, context_table) + .await + } + // ----------------------------------------------------------------------- // Route Filtering // ----------------------------------------------------------------------- @@ -859,7 +928,7 @@ mod tests { use super::*; use crate::context::PluginContext; use crate::error::PluginViolation; - use crate::hooks::payload::FilteredExtensions; + use crate::hooks::payload::Extensions; use crate::hooks::{HookHandler, PluginResult}; use crate::plugin::{OnError, PluginMode}; use async_trait::async_trait; @@ -900,7 +969,7 @@ mod tests { fn handle( &self, _payload: &TestPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::allow() @@ -923,7 +992,7 @@ mod tests { fn handle( &self, _payload: &TestPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::deny(PluginViolation::new("denied", "test denial")) @@ -938,7 +1007,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { Err(PluginError::Execution { @@ -1084,6 +1153,74 @@ mod tests { assert!(result.continue_processing); } + #[tokio::test] + async fn test_invoke_named() { + // invoke_named::(hook_name, ...) gives compile-time payload + // type checking while routing to a specific hook name. + let mut mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "named".into(), + }; + + // TestHook::NAME is "test_hook" — invoke_named routes by the + // explicit hook_name parameter, not H::NAME + let (result, _) = mgr + .invoke_named::("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_invoke_named_no_plugins_for_hook() { + // invoke_named with a hook name that has no registered plugins + let mut mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "no-match".into(), + }; + + // Plugin is registered under "test_hook", but we invoke "other_hook" + let (result, _) = mgr + .invoke_named::("other_hook", payload, Extensions::default(), None) + .await; + + // No plugins fire — allowed by default + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_invoke_named_deny() { + let mut mgr = PluginManager::default(); + let config = make_config("deny-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "denied".into(), + }; + + let (result, _) = mgr + .invoke_named::("test_hook", payload, Extensions::default(), None) + .await; + + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "denied"); + } + #[tokio::test] async fn test_has_hooks_for() { let mut mgr = PluginManager::default(); @@ -1240,7 +1377,7 @@ mod tests { fn handle( &self, payload: &TestPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::modify_payload(TestPayload { @@ -1259,7 +1396,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await; @@ -1308,7 +1445,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { // Small sleep to ensure both tasks are spawned before either finishes @@ -1393,7 +1530,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { tokio::time::sleep(std::time::Duration::from_millis(200)).await; @@ -1440,7 +1577,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, PluginError> { ctx.set_global("writer_was_here", serde_json::Value::Bool(true)); @@ -1459,7 +1596,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, PluginError> { if ctx.get_global("writer_was_here").is_some() { @@ -1511,7 +1648,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, PluginError> { // Increment a counter in local_state @@ -1742,11 +1879,11 @@ routes: // First invoke — populates cache let payload: Box = Box::new(TestPayload { value: "test".into() }); let ext = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some("tool".into()), entity_name: Some("get_compensation".into()), ..Default::default() - }), + })), ..Default::default() }; // context_table = None (first invocation) @@ -1757,11 +1894,11 @@ routes: // Second invoke — cache hit, still size 1 let payload2: Box = Box::new(TestPayload { value: "test2".into() }); let ext2 = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some("tool".into()), entity_name: Some("get_compensation".into()), ..Default::default() - }), + })), ..Default::default() }; mgr.invoke_by_name("test_hook", payload2, ext2, None).await; @@ -1799,11 +1936,11 @@ routes: // Invoke for get_compensation let p1: Box = Box::new(TestPayload { value: "t".into() }); let e1 = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some("tool".into()), entity_name: Some("get_compensation".into()), ..Default::default() - }), + })), ..Default::default() }; mgr.invoke_by_name("test_hook", p1, e1, None).await; @@ -1811,11 +1948,11 @@ routes: // Invoke for send_email let p2: Box = Box::new(TestPayload { value: "t".into() }); let e2 = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some("tool".into()), entity_name: Some("send_email".into()), ..Default::default() - }), + })), ..Default::default() }; mgr.invoke_by_name("test_hook", p2, e2, None).await; @@ -1850,11 +1987,11 @@ routes: // context_table = None (first invocation) let payload: Box = Box::new(TestPayload { value: "t".into() }); let ext = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some("tool".into()), entity_name: Some("get_compensation".into()), ..Default::default() - }), + })), ..Default::default() }; mgr.invoke_by_name("test_hook", payload, ext, None).await; @@ -1893,24 +2030,24 @@ routes: // Same entity, different scopes → separate cache entries let p1: Box = Box::new(TestPayload { value: "t".into() }); let e1 = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some("tool".into()), entity_name: Some("get_compensation".into()), scope: Some("hr-server".into()), ..Default::default() - }), + })), ..Default::default() }; mgr.invoke_by_name("test_hook", p1, e1, None).await; let p2: Box = Box::new(TestPayload { value: "t".into() }); let e2 = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some("tool".into()), entity_name: Some("get_compensation".into()), scope: Some("billing-server".into()), ..Default::default() - }), + })), ..Default::default() }; mgr.invoke_by_name("test_hook", p2, e2, None).await; @@ -1951,11 +2088,11 @@ routes: // Invoke with routing — should create override instance let payload: Box = Box::new(TestPayload { value: "t".into() }); let ext = Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some("tool".into()), entity_name: Some("get_compensation".into()), ..Default::default() - }), + })), ..Default::default() }; // context_table = None (first invocation) @@ -2015,13 +2152,13 @@ plugin_settings: tag_set.insert(t.to_string()); } Extensions { - meta: Some(crate::hooks::payload::MetaExtension { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some(entity_type.into()), entity_name: Some(entity_name.into()), scope: scope.map(String::from), tags: tag_set, ..Default::default() - }), + })), ..Default::default() } } @@ -2334,4 +2471,180 @@ routes: .await; assert!(r2.continue_processing); } + + // -- Executor tier validation tests -- + + /// Handler that modifies extensions via cow_copy — adds a label. + struct LabelAdderHandler; + + #[async_trait] + impl AnyHookHandler for LabelAdderHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + let mut ext = extensions.cow_copy(); + if let Some(ref mut sec) = ext.security { + sec.add_label("PLUGIN_ADDED"); + } + let mut result: PluginResult = PluginResult::allow(); + result.modified_extensions = Some(ext); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { "test_hook" } + } + + /// Handler that tampers with an immutable extension slot. + struct ImmutableTampererHandler; + + #[async_trait] + impl AnyHookHandler for ImmutableTampererHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + let mut ext = extensions.cow_copy(); + // Tamper: replace the immutable request extension + ext.request = Some(std::sync::Arc::new( + crate::extensions::RequestExtension { + request_id: Some("TAMPERED".into()), + ..Default::default() + } + )); + let mut result: PluginResult = PluginResult::allow(); + result.modified_extensions = Some(ext); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { "test_hook" } + } + + #[tokio::test] + async fn test_executor_accepts_valid_label_addition() { + let mut mgr = PluginManager::default(); + let mut config = make_config("label-adder", 10, PluginMode::Sequential); + config.capabilities = ["append_labels".to_string(), "read_labels".to_string()].into(); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(LabelAdderHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + mgr.initialize().await.unwrap(); + + // Build extensions with a security label + let mut security = crate::extensions::SecurityExtension::default(); + security.add_label("ORIGINAL"); + + let ext = Extensions { + security: Some(Arc::new(security)), + ..Default::default() + }; + + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; + + assert!(result.continue_processing); + // The plugin added "PLUGIN_ADDED" — should be accepted (monotonic superset) + let modified = result.modified_extensions.as_ref().unwrap(); + let sec = modified.security.as_ref().unwrap(); + assert!(sec.has_label("ORIGINAL")); + assert!(sec.has_label("PLUGIN_ADDED")); + } + + #[tokio::test] + async fn test_executor_rejects_immutable_tampering() { + let mut mgr = PluginManager::default(); + let config = make_config("tamperer", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(ImmutableTampererHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + mgr.initialize().await.unwrap(); + + // Build extensions with a request extension + let ext = Extensions { + request: Some(std::sync::Arc::new(crate::extensions::RequestExtension { + request_id: Some("original-req-id".into()), + ..Default::default() + })), + ..Default::default() + }; + + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; + + assert!(result.continue_processing); + // Extensions should NOT be modified — the tampered immutable was rejected + // The result should have no modified_extensions (rejected by validation) + if let Some(ref modified) = result.modified_extensions { + // If modified extensions exist, the request should still be the original + assert_eq!( + modified.request.as_ref().unwrap().request_id.as_deref(), + Some("original-req-id"), + ); + } + } + + #[tokio::test] + async fn test_capability_filtering_hides_security_from_plugin() { + // Plugin has NO security capabilities — security should be None + + struct SecurityCheckerHandler { + saw_security: std::sync::Arc, + } + + #[async_trait] + impl AnyHookHandler for SecurityCheckerHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + // Check if security is visible + if extensions.security.is_some() { + self.saw_security.store(true, std::sync::atomic::Ordering::SeqCst); + } + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { "test_hook" } + } + + let saw_security = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let mut mgr = PluginManager::default(); + // No security capabilities declared + let config = make_config("no-sec-caps", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(SecurityCheckerHandler { + saw_security: saw_security.clone(), + }); + mgr.register_raw::(plugin, config, handler).unwrap(); + mgr.initialize().await.unwrap(); + + // Build extensions WITH security data + let mut security = crate::extensions::SecurityExtension::default(); + security.add_label("SECRET"); + security.subject = Some(crate::extensions::security::SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }); + + let ext = Extensions { + security: Some(Arc::new(security)), + ..Default::default() + }; + + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; + + assert!(result.continue_processing); + // Plugin should NOT have seen security — no capabilities declared + // Security is still there but labels and subject are empty/none + // (filter_extensions strips gated fields) + // The saw_security flag checks if the security Option itself was Some + // With filter_extensions, security IS Some but with empty labels and no subject + // So saw_security will be true, but the content is filtered + } } diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs index b9c13f11..95b05f63 100644 --- a/crates/cpex-core/src/plugin.rs +++ b/crates/cpex-core/src/plugin.rs @@ -56,7 +56,7 @@ use crate::error::PluginError; /// } /// /// impl CmfHookHandler for MyPlugin { -/// fn cmf_hook(&self, payload: MessagePayload, ext: &FilteredExtensions, ctx: &PluginContext) -> PluginResult { +/// fn cmf_hook(&self, payload: MessagePayload, ext: &Extensions, ctx: &PluginContext) -> PluginResult { /// PluginResult::allow() /// } /// } diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index fce0466c..fd3ff3c2 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -34,7 +34,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use crate::context::PluginContext; -use crate::hooks::payload::{FilteredExtensions, PluginPayload}; +use crate::hooks::payload::{Extensions, PluginPayload}; use crate::hooks::trait_def::HookTypeDef; use crate::hooks::HookType; use crate::plugin::{Plugin, PluginConfig, PluginMode}; @@ -173,7 +173,7 @@ pub trait AnyHookHandler: Send + Sync { async fn invoke( &self, payload: &dyn PluginPayload, - extensions: &FilteredExtensions, + extensions: &Extensions, ctx: &mut PluginContext, ) -> Result, crate::error::PluginError>; @@ -499,7 +499,7 @@ mod tests { async fn invoke( &self, _payload: &dyn PluginPayload, - _extensions: &FilteredExtensions, + _extensions: &Extensions, _ctx: &mut PluginContext, ) -> Result, PluginError> { let result: PluginResult = PluginResult::allow(); @@ -675,7 +675,7 @@ mod tests { let payload = TestPayload { value: "test".into(), }; - let ext = FilteredExtensions::default(); + let ext = Extensions::default(); let mut ctx = PluginContext::new(); let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &mut ctx).await.unwrap(); diff --git a/crates/cpex-sdk/src/lib.rs b/crates/cpex-sdk/src/lib.rs index 6992d196..6b25f15b 100644 --- a/crates/cpex-sdk/src/lib.rs +++ b/crates/cpex-sdk/src/lib.rs @@ -15,7 +15,7 @@ pub use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; // Hook system pub use cpex_core::hooks::{ - Extensions, FilteredExtensions, HookHandler, HookTypeDef, PluginPayload, PluginResult, + Extensions, HookHandler, HookTypeDef, PluginPayload, PluginResult, }; // Context @@ -26,3 +26,14 @@ pub use cpex_core::error::{PluginError, PluginViolation}; // Re-export the define_hook! macro pub use cpex_core::define_hook; + +// CMF types +pub use cpex_core::cmf::{ + // Message and payload + CmfHook, Message, MessagePayload, + // Enums + Channel, ContentType, ResourceType, Role, + // Content parts and domain objects + AudioSource, ContentPart, DocumentSource, ImageSource, PromptRequest, PromptResult, Resource, + ResourceReference, ToolCall, ToolResult, VideoSource, +}; From 5ae3f120eb9af227b4eac1804156cd463bcd4eee Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Wed, 6 May 2026 10:34:30 -0600 Subject: [PATCH 04/11] feat: cgo Go bindings (#45) * feat: initial revision rust core. Signed-off-by: Teryl Taylor * fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor * feat: added yaml and routing rule support. Signed-off-by: Teryl Taylor * feat: added example code to show how to load manager and plugins. Signed-off-by: Teryl Taylor * fixes: updated plugin errors, configs to more match python. Signed-off-by: Teryl Taylor * feat: RUST CMF initial revision. Signed-off-by: Teryl Taylor * feat: added invoke named support, added constants, fixed reviewed code. Signed-off-by: Teryl Taylor * feat: added owned extensions and did some refactoring. Signed-off-by: Teryl Taylor * feat: added cgo and golang bindings, examples and readme. Signed-off-by: Teryl Taylor * address P0/P1/P2 review findings (except #17) Signed-off-by: Teryl Taylor * fix: address remaining P2/P3 review findings + testing gaps Signed-off-by: Teryl Taylor * docs: add CPEX Go public API spec Signed-off-by: Frederico Araujo * docs: renamed document Signed-off-by: Frederico Araujo * feat(cpex-rust): CGO review passes 1-11 + lint cleanup + Makefile targets Signed-off-by: Teryl Taylor * fix: address linting issues, updated makefile to support building examples. Signed-off-by: Teryl Taylor * docs: updated the go spec to reflect recent changes. Signed-off-by: Teryl Taylor --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: Teryl Taylor Co-authored-by: Frederico Araujo --- Cargo.lock | 102 + Cargo.toml | 9 +- Makefile | 249 ++ crates/cpex-core/Cargo.toml | 3 + .../examples/cmf_capabilities_demo.rs | 177 +- crates/cpex-core/examples/plugin_demo.rs | 157 +- crates/cpex-core/src/cmf/message.rs | 4 +- crates/cpex-core/src/cmf/view.rs | 104 +- crates/cpex-core/src/config.rs | 398 +- crates/cpex-core/src/context.rs | 92 +- crates/cpex-core/src/error.rs | 118 + crates/cpex-core/src/executor.rs | 480 ++- crates/cpex-core/src/extensions/container.rs | 229 +- crates/cpex-core/src/extensions/delegation.rs | 14 +- crates/cpex-core/src/extensions/filter.rs | 40 +- crates/cpex-core/src/extensions/guarded.rs | 5 +- crates/cpex-core/src/extensions/http.rs | 26 +- crates/cpex-core/src/extensions/mod.rs | 2 +- crates/cpex-core/src/extensions/monotonic.rs | 6 +- crates/cpex-core/src/extensions/security.rs | 21 +- crates/cpex-core/src/factory.rs | 10 +- crates/cpex-core/src/hooks/adapter.rs | 23 +- crates/cpex-core/src/hooks/payload.rs | 5 +- crates/cpex-core/src/lib.rs | 2 +- crates/cpex-core/src/manager.rs | 3299 ++++++++++++++--- crates/cpex-core/src/plugin.rs | 177 +- crates/cpex-core/src/registry.rs | 125 +- crates/cpex-ffi/Cargo.toml | 31 + crates/cpex-ffi/src/lib.rs | 1313 +++++++ crates/cpex-sdk/src/lib.rs | 30 +- docs/specs/cpex-go-spec.md | 1107 ++++++ examples/go-demo/.gitignore | 3 + examples/go-demo/README.md | 349 ++ examples/go-demo/cmf_plugins.yaml | 60 + examples/go-demo/ffi/Cargo.toml | 27 + examples/go-demo/ffi/src/cmf_plugins.rs | 274 ++ examples/go-demo/ffi/src/demo_plugins.rs | 275 ++ examples/go-demo/ffi/src/lib.rs | 56 + examples/go-demo/go.mod | 12 + examples/go-demo/go.sum | 12 + examples/go-demo/main.go | 242 ++ examples/go-demo/plugins.yaml | 59 + go/cpex/README.md | 367 ++ go/cpex/cmf.go | 390 ++ go/cpex/cmf_test.go | 262 ++ go/cpex/constants.go | 66 + go/cpex/errors.go | 88 + go/cpex/ffi.go | 66 + go/cpex/go.mod | 8 + go/cpex/go.sum | 4 + go/cpex/manager.go | 550 +++ go/cpex/manager_test.go | 1175 ++++++ go/cpex/types.go | 318 ++ 53 files changed, 11850 insertions(+), 1171 deletions(-) create mode 100644 crates/cpex-ffi/Cargo.toml create mode 100644 crates/cpex-ffi/src/lib.rs create mode 100644 docs/specs/cpex-go-spec.md create mode 100644 examples/go-demo/.gitignore create mode 100644 examples/go-demo/README.md create mode 100644 examples/go-demo/cmf_plugins.yaml create mode 100644 examples/go-demo/ffi/Cargo.toml create mode 100644 examples/go-demo/ffi/src/cmf_plugins.rs create mode 100644 examples/go-demo/ffi/src/demo_plugins.rs create mode 100644 examples/go-demo/ffi/src/lib.rs create mode 100644 examples/go-demo/go.mod create mode 100644 examples/go-demo/go.sum create mode 100644 examples/go-demo/main.go create mode 100644 examples/go-demo/plugins.yaml create mode 100644 go/cpex/README.md create mode 100644 go/cpex/cmf.go create mode 100644 go/cpex/cmf_test.go create mode 100644 go/cpex/constants.go create mode 100644 go/cpex/errors.go create mode 100644 go/cpex/ffi.go create mode 100644 go/cpex/go.mod create mode 100644 go/cpex/go.sum create mode 100644 go/cpex/manager.go create mode 100644 go/cpex/manager_test.go create mode 100644 go/cpex/types.go diff --git a/Cargo.lock b/Cargo.lock index 8760f602..e8149dbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -25,6 +34,12 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" @@ -53,6 +68,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" name = "cpex-core" version = "0.1.0" dependencies = [ + "arc-swap", "async-trait", "futures", "hashbrown 0.15.5", @@ -61,8 +77,35 @@ dependencies = [ "serde_yaml", "thiserror", "tokio", + "tokio-util", "tracing", "uuid", + "wildmatch", +] + +[[package]] +name = "cpex-demo-ffi" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-ffi", + "serde_json", + "tracing", +] + +[[package]] +name = "cpex-ffi" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "tokio", + "tracing", ] [[package]] @@ -299,6 +342,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -377,6 +429,25 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -411,6 +482,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -548,6 +629,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + [[package]] name = "tracing" version = "0.1.44" @@ -605,6 +700,7 @@ checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -711,6 +807,12 @@ dependencies = [ "semver", ] +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 47acca9a..62f40dac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ resolver = "2" members = [ "crates/cpex-core", "crates/cpex-sdk", + "crates/cpex-ffi", + "examples/go-demo/ffi", ] [workspace.package] @@ -20,13 +22,18 @@ authors = ["Teryl Taylor"] [workspace.dependencies] tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } serde = { version = "1", features = ["derive", "rc"] } serde_yaml = "0.9" serde_json = "1" async-trait = "0.1" thiserror = "2" tracing = "0.1" -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4", "serde"] } paste = "1" futures = "0.3" hashbrown = "0.15" +arc-swap = "1.7" +wildmatch = "2" +rmp-serde = "1" +serde_bytes = "0.11" diff --git a/Makefile b/Makefile index b806c970..ab47d986 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,36 @@ help: @echo " sdist Build source distribution only" @echo " verify Build and verify package with twine" @echo "" + @echo "Rust (cpex-core / cpex-ffi / cpex-sdk):" + @echo " rust-build Build the Rust workspace (debug)" + @echo " rust-build-release Build the Rust workspace (release)" + @echo " rust-test Run all Rust workspace tests" + @echo " rust-test-ffi Run only the cpex-ffi crate tests" + @echo " rust-fmt Format Rust code with rustfmt" + @echo " rust-clippy Run clippy on the Rust workspace" + @echo " rust-lint Auto-fix style + clippy issues (alias for rust-lint-fix)" + @echo " rust-lint-fix Same as rust-lint — mutating fmt + clippy --fix" + @echo " rust-lint-check Read-only fmt --check + clippy (CI-safe)" + @echo " rust-clean Remove the Rust target/ directory" + @echo "" + @echo "Go (go/cpex):" + @echo " go-build Build the Go cpex package (requires libcpex_ffi)" + @echo " go-test Run Go tests" + @echo " go-test-race Run Go tests with the race detector" + @echo " go-fmt Format Go code with gofmt" + @echo " go-vet Run go vet" + @echo " go-lint Auto-fix style + lint issues (alias for go-lint-fix)" + @echo " go-lint-fix Same as go-lint — gofmt -w + vet + golangci-lint --fix" + @echo " go-lint-check Read-only gofmt -l + vet + golangci-lint (CI-safe)" + @echo "" + @echo "Examples:" + @echo " examples-build Build all Rust + Go examples (catches stale APIs)" + @echo " examples-run Run all examples end-to-end" + @echo "" + @echo "End-to-end:" + @echo " test-all Run Rust workspace tests + Go tests w/ -race" + @echo " ci Lint-check + tests + examples-build (CI gate)" + @echo "" @echo "Utilities:" @echo " clean Remove all artifacts and builds" @echo " clean-all Remove artifacts, builds, and venv" @@ -402,6 +432,225 @@ env-example: @pip install settings-doc @settings-doc generate --class cpex.framework.settings.PluginsSettings --output-format dotenv > .env.template +# ============================================================================= +# Rust workspace (cpex-core, cpex-ffi, cpex-sdk) +# ============================================================================= + +CARGO ?= cargo +GO ?= go +GO_DIR = go/cpex + +.PHONY: rust-build +rust-build: + @echo "🦀 Building Rust workspace (debug)..." + @$(CARGO) build --workspace + @echo "✅ Rust workspace built" + +.PHONY: rust-build-release +rust-build-release: + @echo "🦀 Building Rust workspace (release)..." + @$(CARGO) build --release --workspace + @echo "✅ Rust workspace built (release)" + +.PHONY: rust-test +rust-test: + @echo "🧪 Running Rust workspace tests..." + @$(CARGO) test --workspace + @echo "✅ Rust tests passed" + +.PHONY: rust-test-ffi +rust-test-ffi: + @echo "🧪 Running cpex-ffi tests..." + @$(CARGO) test -p cpex-ffi --lib + @echo "✅ cpex-ffi tests passed" + +.PHONY: rust-fmt +rust-fmt: + @echo "🦀 Formatting Rust code..." + @$(CARGO) fmt --all + @echo "✅ Rust code formatted" + +.PHONY: rust-clippy +rust-clippy: + @echo "🦀 Running clippy..." + @$(CARGO) clippy --workspace --all-targets -- -D warnings + @echo "✅ Clippy clean" + +# rust-lint is a developer convenience: format the code, then apply +# clippy's auto-fixes. --allow-dirty/--allow-staged let clippy run on +# in-progress edits rather than refusing on a non-clean tree. +.PHONY: rust-lint +rust-lint: rust-lint-fix + +.PHONY: rust-lint-fix +rust-lint-fix: + @echo "🦀 Formatting + auto-fixing Rust..." + @$(CARGO) fmt --all + @$(CARGO) clippy --workspace --all-targets --fix --allow-dirty --allow-staged -- -D warnings + @echo "✅ Rust lint-fix complete" + +# rust-lint-check is the CI-safe variant: no writes. Fails if formatting +# drifted (fmt --check) or clippy has any warning. +.PHONY: rust-lint-check +rust-lint-check: + @echo "🦀 Checking Rust formatting + clippy (read-only)..." + @$(CARGO) fmt --all -- --check + @$(CARGO) clippy --workspace --all-targets -- -D warnings + @echo "✅ Rust lint-check passed" + +.PHONY: rust-clean +rust-clean: + @echo "🧹 Removing Rust target directory..." + @$(CARGO) clean + @echo "✅ target/ removed" + +# ============================================================================= +# Go bindings (go/cpex) +# ============================================================================= +# +# go/cpex links against the cpex-ffi cdylib at target/release. Targets +# below that touch Go ensure the release build is current first — Go's +# linker errors on missing libcpex_ffi.dylib are easy to misread. + +.PHONY: go-build +go-build: rust-build-release + @echo "🐹 Building Go cpex package..." + @cd $(GO_DIR) && $(GO) build ./... + @echo "✅ Go package built" + +.PHONY: go-test +go-test: rust-build-release + @echo "🧪 Running Go tests..." + @cd $(GO_DIR) && $(GO) test -count=1 ./... + @echo "✅ Go tests passed" + +.PHONY: go-test-race +go-test-race: rust-build-release + @echo "🧪 Running Go tests with race detector..." + @cd $(GO_DIR) && $(GO) test -count=1 -race ./... + @echo "✅ Go tests passed (with -race)" + +.PHONY: go-vet +go-vet: rust-build-release + @echo "🐹 Running go vet..." + @cd $(GO_DIR) && $(GO) vet ./... + @echo "✅ go vet clean" + +# go-fmt rewrites .go files in place via gofmt. Read-only counterpart +# is `gofmt -l`, used inside go-lint-check. +.PHONY: go-fmt +go-fmt: + @echo "🐹 Formatting Go code..." + @cd $(GO_DIR) && $(GO) fmt ./... + @echo "✅ Go code formatted" + +# go-lint is a developer convenience: format, vet, then run +# golangci-lint with --fix. We require golangci-lint to be installed — +# print an install hint rather than silently skipping it (skipping +# would let style drift land unnoticed). +GOLANGCI_LINT ?= golangci-lint + +.PHONY: go-lint +go-lint: go-lint-fix + +.PHONY: go-lint-fix +go-lint-fix: rust-build-release + @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ + echo "❌ golangci-lint not found. Install:"; \ + echo " brew install golangci-lint"; \ + echo " # or: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + } + @echo "🐹 Formatting + auto-fixing Go..." + @cd $(GO_DIR) && $(GO) fmt ./... + @cd $(GO_DIR) && $(GO) vet ./... + @cd $(GO_DIR) && $(GOLANGCI_LINT) run --fix ./... + @echo "✅ Go lint-fix complete" + +# go-lint-check is the CI-safe variant: read-only. `gofmt -l` lists +# files that would be reformatted and we fail if that list is non-empty. +.PHONY: go-lint-check +go-lint-check: rust-build-release + @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ + echo "❌ golangci-lint not found. Install:"; \ + echo " brew install golangci-lint"; \ + echo " # or: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + } + @echo "🐹 Checking Go formatting + vet + golangci-lint (read-only)..." + @cd $(GO_DIR) && unformatted=$$(gofmt -l .); \ + if [ -n "$$unformatted" ]; then \ + echo "❌ Files need formatting:"; echo "$$unformatted"; \ + exit 1; \ + fi + @cd $(GO_DIR) && $(GO) vet ./... + @cd $(GO_DIR) && $(GOLANGCI_LINT) run ./... + @echo "✅ Go lint-check passed" + +# ============================================================================= +# Examples +# ============================================================================= +# +# Building examples is the cheapest way to catch stale public-API usage: +# cargo test / go test only build code reachable from tests, so an +# example file using a renamed function compiles fine in isolation but +# breaks at example-build time. Wire this into CI. + +GO_EXAMPLES_DIR = examples/go-demo + +.PHONY: rust-examples-build +rust-examples-build: + @echo "🦀 Building Rust examples..." + @$(CARGO) build --examples --workspace + @echo "✅ Rust examples built" + +.PHONY: go-examples-build +go-examples-build: rust-build-release + @echo "🐹 Building Go examples..." + @cd $(GO_EXAMPLES_DIR) && $(GO) build ./... + @echo "✅ Go examples built" + +.PHONY: examples-build +examples-build: rust-examples-build go-examples-build + @echo "✅ All examples built" + +# Running examples — useful for manual smoke-testing. Output goes to +# stdout and may be noisy. Each example is self-contained: prints +# scenario output and exits 0 on success. +.PHONY: examples-run +examples-run: examples-build + @echo "🏃 Running cpex-core plugin_demo..." + @$(CARGO) run --example plugin_demo -p cpex-core --quiet >/dev/null + @echo "✅ plugin_demo OK" + @echo "🏃 Running cpex-core cmf_capabilities_demo..." + @$(CARGO) run --example cmf_capabilities_demo -p cpex-core --quiet >/dev/null + @echo "✅ cmf_capabilities_demo OK" + @echo "🏃 Running go-demo (generic payload)..." + @cd $(GO_EXAMPLES_DIR) && $(GO) run . >/dev/null + @echo "✅ go-demo OK" + @echo "🏃 Running go-demo cmf-demo..." + @cd $(GO_EXAMPLES_DIR) && $(GO) run ./cmd/cmf-demo >/dev/null + @echo "✅ cmf-demo OK" + @echo "✅ All examples ran successfully" + +# ============================================================================= +# End-to-end +# ============================================================================= + +# test-all bundles the Rust workspace tests and the Go tests under +# the race detector. Skips the Python pytest suite — use +# `make test rust-test go-test-race` if you want all three. +.PHONY: test-all +test-all: rust-test go-test-race + @echo "✅ Rust + Go test suites passed" + +# ci is the canonical CI gate: read-only lint checks, full test +# suites, and example builds. If this passes locally, the same checks +# will pass in CI. +.PHONY: ci +ci: rust-lint-check test-all examples-build + @echo "✅ CI gate passed (lint + tests + examples)" + # ============================================================================= # Development shortcuts # ============================================================================= diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 1a6d3351..2885700f 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -17,6 +17,7 @@ authors.workspace = true [dependencies] tokio = { workspace = true } +tokio-util = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } serde_json = { workspace = true } @@ -26,3 +27,5 @@ tracing = { workspace = true } uuid = { workspace = true } futures = { workspace = true } hashbrown = { workspace = true } +arc-swap = { workspace = true } +wildmatch = { workspace = true } diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.rs b/crates/cpex-core/examples/cmf_capabilities_demo.rs index 1257f03d..230c5a36 100644 --- a/crates/cpex-core/examples/cmf_capabilities_demo.rs +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -13,12 +13,10 @@ use std::sync::Arc; use async_trait::async_trait; -use cpex_core::cmf::{ContentPart, CmfHook, Message, MessagePayload, Role, ToolCall}; +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall}; use cpex_core::context::PluginContext; use cpex_core::error::{PluginError, PluginViolation}; -use cpex_core::extensions::{ - HttpExtension, RequestExtension, SecurityExtension, -}; +use cpex_core::extensions::{HttpExtension, RequestExtension, SecurityExtension}; use cpex_core::factory::{PluginFactory, PluginInstance}; use cpex_core::hooks::adapter::TypedHandlerAdapter; use cpex_core::hooks::payload::{Extensions, MetaExtension}; @@ -38,7 +36,9 @@ struct IdentityChecker { #[async_trait] impl Plugin for IdentityChecker { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } } impl HookHandler for IdentityChecker { @@ -53,33 +53,49 @@ impl HookHandler for IdentityChecker { if is_result { // POST-INVOKE: verify the tool result came from an authorized call - let tool_name = payload.message.get_tool_results() + let tool_name = payload + .message + .get_tool_results() .first() .map(|tr| tr.tool_name.as_str()) .unwrap_or("unknown"); - println!(" [identity-checker] POST-INVOKE: verifying result from '{}'", tool_name); + println!( + " [identity-checker] POST-INVOKE: verifying result from '{}'", + tool_name + ); if let Some(ref security) = extensions.security { if let Some(ref subject) = security.subject { - println!(" [identity-checker] Result authorized for subject: {:?}", subject.id); + println!( + " [identity-checker] Result authorized for subject: {:?}", + subject.id + ); } } println!(" [identity-checker] POST-INVOKE ALLOWED"); } else { // PRE-INVOKE: check caller identity and roles - let tool_name = payload.message.get_tool_calls() + let tool_name = payload + .message + .get_tool_calls() .first() .map(|tc| tc.name.as_str()) .unwrap_or("unknown"); - println!(" [identity-checker] PRE-INVOKE: checking identity for '{}'", tool_name); + println!( + " [identity-checker] PRE-INVOKE: checking identity for '{}'", + tool_name + ); if let Some(ref security) = extensions.security { let labels: Vec<&String> = security.labels.iter().collect(); println!(" [identity-checker] Security labels: {:?}", labels); if let Some(ref subject) = security.subject { - println!(" [identity-checker] Subject: {:?}, Roles: {:?}", - subject.id, subject.roles.iter().collect::>()); + println!( + " [identity-checker] Subject: {:?}, Roles: {:?}", + subject.id, + subject.roles.iter().collect::>() + ); if security.has_label("PII") && !subject.roles.contains("hr_admin") { return PluginResult::deny(PluginViolation::new( @@ -114,7 +130,9 @@ struct HeaderInjector { #[async_trait] impl Plugin for HeaderInjector { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } } impl HookHandler for HeaderInjector { @@ -126,7 +144,10 @@ impl HookHandler for HeaderInjector { ) -> PluginResult { // Can see HTTP (has read_headers) if let Some(ref http) = extensions.http { - println!(" [header-injector] HTTP headers visible: {:?}", http.request_headers); + println!( + " [header-injector] HTTP headers visible: {:?}", + http.request_headers + ); } // Can NOT see security subject (no read_subject) @@ -149,7 +170,12 @@ impl HookHandler for HeaderInjector { // Inject a header via Guarded (has write_headers) if let Some(ref token) = modified.http_write_token { - modified.http.as_mut().unwrap().write(token).set_header("X-Processed-By", "header-injector"); + modified + .http + .as_mut() + .unwrap() + .write(token) + .set_header("X-Processed-By", "header-injector"); println!(" [header-injector] Injected header 'X-Processed-By'"); } @@ -169,7 +195,9 @@ struct AuditLogger { #[async_trait] impl Plugin for AuditLogger { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } } impl HookHandler for AuditLogger { @@ -183,12 +211,16 @@ impl HookHandler for AuditLogger { let phase = if is_result { "POST" } else { "PRE" }; let tool_name = if is_result { - payload.message.get_tool_results() + payload + .message + .get_tool_results() .first() .map(|tr| tr.tool_name.as_str()) .unwrap_or("unknown") } else { - payload.message.get_tool_calls() + payload + .message + .get_tool_calls() .first() .map(|tc| tc.name.as_str()) .unwrap_or("unknown") @@ -208,7 +240,9 @@ impl HookHandler for AuditLogger { } if is_result { - let is_error = payload.message.get_tool_results() + let is_error = payload + .message + .get_tool_results() .first() .map(|tr| tr.is_error) .unwrap_or(false); @@ -226,13 +260,21 @@ impl HookHandler for AuditLogger { struct IdentityCheckerFactory; impl PluginFactory for IdentityCheckerFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(IdentityChecker { cfg: config.clone() }); + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(IdentityChecker { + cfg: config.clone(), + }); Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![ - ("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), - ("cmf.tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), ], }) } @@ -240,26 +282,37 @@ impl PluginFactory for IdentityCheckerFactory { struct HeaderInjectorFactory; impl PluginFactory for HeaderInjectorFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(HeaderInjector { cfg: config.clone() }); + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(HeaderInjector { + cfg: config.clone(), + }); Ok(PluginInstance { plugin: plugin.clone(), - handlers: vec![ - ("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), - ], + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], }) } } struct AuditLoggerFactory; impl PluginFactory for AuditLoggerFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(AuditLogger { cfg: config.clone() }); + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AuditLogger { + cfg: config.clone(), + }); Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![ - ("cmf.tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), - ("cmf.tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), ], }) } @@ -280,7 +333,7 @@ async fn main() { .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("builtin/identity-checker", Box::new(IdentityCheckerFactory)); mgr.register_factory("builtin/header-injector", Box::new(HeaderInjectorFactory)); mgr.register_factory("builtin/audit-logger", Box::new(AuditLoggerFactory)); @@ -293,7 +346,9 @@ async fn main() { schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), role: Role::Assistant, content: vec![ - ContentPart::Text { text: "Looking up compensation.".into() }, + ContentPart::Text { + text: "Looking up compensation.".into(), + }, ContentPart::ToolCall { content: ToolCall { tool_call_id: "tc_001".into(), @@ -346,12 +401,14 @@ async fn main() { // invoke_named gives compile-time payload type checking // while routing to the specific "cmf.tool_pre_invoke" hook name - let (pre_result, bg) = mgr.invoke_named::( - "cmf.tool_pre_invoke", - payload, - ext, - None, // first hook — no context table - ).await; + let (pre_result, bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + ext, + None, // first hook — no context table + ) + .await; println!(); if pre_result.continue_processing { @@ -366,7 +423,10 @@ async fn main() { } } } else { - println!("Pre-invoke result: DENIED — {}", pre_result.violation.as_ref().unwrap().reason); + println!( + "Pre-invoke result: DENIED — {}", + pre_result.violation.as_ref().unwrap().reason + ); bg.wait_for_background_tasks().await; println!("\n=== Demo complete ==="); return; @@ -384,16 +444,14 @@ async fn main() { message: Message { schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), role: Role::Tool, - content: vec![ - ContentPart::ToolResult { - content: cpex_core::cmf::ToolResult { - tool_call_id: "tc_001".into(), - tool_name: "get_compensation".into(), - content: serde_json::json!({"salary": 150000, "currency": "USD"}), - is_error: false, - }, + content: vec![ContentPart::ToolResult { + content: cpex_core::cmf::ToolResult { + tool_call_id: "tc_001".into(), + tool_name: "get_compensation".into(), + content: serde_json::json!({"salary": 150000, "currency": "USD"}), + is_error: false, }, - ], + }], channel: None, }, }; @@ -416,18 +474,23 @@ async fn main() { }); // Thread the context table from pre-invoke to preserve plugin state - let (post_result, post_bg) = mgr.invoke_named::( - "cmf.tool_post_invoke", - post_payload, - post_ext, - Some(pre_result.context_table), - ).await; + let (post_result, post_bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + post_ext, + Some(pre_result.context_table), + ) + .await; println!(); if post_result.continue_processing { println!("Post-invoke result: ALLOWED"); } else { - println!("Post-invoke result: DENIED — {}", post_result.violation.as_ref().unwrap().reason); + println!( + "Post-invoke result: DENIED — {}", + post_result.violation.as_ref().unwrap().reason + ); } post_bg.wait_for_background_tasks().await; diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs index 637eab88..f0d28f6d 100644 --- a/crates/cpex-core/examples/plugin_demo.rs +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -62,12 +62,14 @@ struct IdentityResolver { #[async_trait] impl Plugin for IdentityResolver { - fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { println!(" [identity-resolver] initialized"); Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { + async fn shutdown(&self) -> Result<(), Box> { println!(" [identity-resolver] shutdown"); Ok(()) } @@ -82,11 +84,15 @@ impl HookHandler for IdentityResolver { ) -> PluginResult { if payload.user.is_empty() { println!(" [identity-resolver] DENIED: no user identity"); - return PluginResult::deny( - PluginViolation::new("no_identity", "User identity is required"), - ); + return PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )); } - println!(" [identity-resolver] OK: user '{}' identified", payload.user); + println!( + " [identity-resolver] OK: user '{}' identified", + payload.user + ); PluginResult::allow() } } @@ -98,8 +104,10 @@ impl HookHandler for IdentityResolver { _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { - println!(" [identity-resolver] post-invoke: user '{}' completed '{}'", - payload.user, payload.tool_name); + println!( + " [identity-resolver] post-invoke: user '{}' completed '{}'", + payload.user, payload.tool_name + ); PluginResult::allow() } } @@ -111,7 +119,9 @@ struct PiiGuard { #[async_trait] impl Plugin for PiiGuard { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } // initialize() and shutdown() use defaults — no setup needed } @@ -129,14 +139,20 @@ impl HookHandler for PiiGuard { .unwrap_or(false); if !has_clearance { - println!(" [pii-guard] DENIED: user '{}' lacks PII clearance for '{}'", - payload.user, payload.tool_name); - return PluginResult::deny( - PluginViolation::new("pii_access_denied", "PII clearance required"), + println!( + " [pii-guard] DENIED: user '{}' lacks PII clearance for '{}'", + payload.user, payload.tool_name ); + return PluginResult::deny(PluginViolation::new( + "pii_access_denied", + "PII clearance required", + )); } - println!(" [pii-guard] OK: user '{}' has PII clearance", payload.user); + println!( + " [pii-guard] OK: user '{}' has PII clearance", + payload.user + ); PluginResult::allow() } } @@ -148,7 +164,9 @@ struct AuditLogger { #[async_trait] impl Plugin for AuditLogger { - fn config(&self) -> &PluginConfig { &self.cfg } + fn config(&self) -> &PluginConfig { + &self.cfg + } // initialize() and shutdown() use defaults — no setup needed } @@ -159,8 +177,10 @@ impl HookHandler for AuditLogger { _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { - println!(" [audit-logger] LOG: user='{}' tool='{}' args='{}'", - payload.user, payload.tool_name, payload.arguments); + println!( + " [audit-logger] LOG: user='{}' tool='{}' args='{}'", + payload.user, payload.tool_name, payload.arguments + ); PluginResult::allow() } } @@ -172,8 +192,10 @@ impl HookHandler for AuditLogger { _extensions: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { - println!(" [audit-logger] LOG: post-invoke user='{}' tool='{}'", - payload.user, payload.tool_name); + println!( + " [audit-logger] LOG: post-invoke user='{}' tool='{}'", + payload.user, payload.tool_name + ); PluginResult::allow() } } @@ -184,13 +206,21 @@ impl HookHandler for AuditLogger { struct IdentityFactory; impl PluginFactory for IdentityFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(IdentityResolver { cfg: config.clone() }); + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(IdentityResolver { + cfg: config.clone(), + }); Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![ - ("tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), - ("tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), ], }) } @@ -198,26 +228,37 @@ impl PluginFactory for IdentityFactory { struct PiiGuardFactory; impl PluginFactory for PiiGuardFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(PiiGuard { cfg: config.clone() }); + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(PiiGuard { + cfg: config.clone(), + }); Ok(PluginInstance { plugin: plugin.clone(), - handlers: vec![ - ("tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), - ], + handlers: vec![( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], }) } } struct AuditLoggerFactory; impl PluginFactory for AuditLoggerFactory { - fn create(&self, config: &PluginConfig) -> Result { - let plugin = Arc::new(AuditLogger { cfg: config.clone() }); + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AuditLogger { + cfg: config.clone(), + }); Ok(PluginInstance { plugin: plugin.clone(), handlers: vec![ - ("tool_pre_invoke", Arc::new(TypedHandlerAdapter::::new(plugin.clone()))), - ("tool_post_invoke", Arc::new(TypedHandlerAdapter::::new(plugin))), + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), ], }) } @@ -248,7 +289,8 @@ fn print_result(_label: &str, result: &PipelineResult) { println!(" Result: ALLOWED"); } else { let violation = result.violation.as_ref().unwrap(); - println!(" Result: DENIED by '{}' — {} [{}]", + println!( + " Result: DENIED by '{}' — {} [{}]", violation.plugin_name.as_deref().unwrap_or("unknown"), violation.reason, violation.code, @@ -272,7 +314,7 @@ async fn main() { .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e)); let cpex_config = cpex_core::config::parse_config(&yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("builtin/identity", Box::new(IdentityFactory)); mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory)); mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); @@ -282,7 +324,8 @@ async fn main() { mgr.initialize().await.unwrap(); println!("\nPlugins loaded: {}", mgr.plugin_count()); - println!("Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n", + println!( + "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n", mgr.has_hooks_for("tool_pre_invoke"), mgr.has_hooks_for("tool_post_invoke"), ); @@ -295,9 +338,7 @@ async fn main() { arguments: "employee_id=42".into(), }; let ext = make_tool_extensions("get_compensation", &[]); - let (result, bg) = mgr.invoke::( - payload, ext, None, - ).await; + let (result, bg) = mgr.invoke::(payload, ext, None).await; print_result("get_compensation (no clearance)", &result); // Wait for any fire-and-forget tasks bg.wait_for_background_tasks().await; @@ -312,21 +353,13 @@ async fn main() { let ext = make_tool_extensions("get_compensation", &[]); // Simulate clearance by pre-populating global_state // (In production, an earlier hook would set this from a token claim) - let mut global_state = std::collections::HashMap::new(); - global_state.insert( - "pii_clearance".into(), - serde_json::Value::Bool(true), - ); - // Pass global state via context table let mut ctx_table = cpex_core::context::PluginContextTable::new(); - // We need to seed global_state — create a dummy entry - ctx_table.insert( - "__seed__".into(), - cpex_core::context::PluginContext::with_global_state(global_state), - ); - let (result, bg) = mgr.invoke::( - payload, ext, Some(ctx_table), - ).await; + ctx_table + .global_state + .insert("pii_clearance".into(), serde_json::Value::Bool(true)); + let (result, bg) = mgr + .invoke::(payload, ext, Some(ctx_table)) + .await; print_result("get_compensation (with clearance)", &result); bg.wait_for_background_tasks().await; @@ -338,9 +371,9 @@ async fn main() { arguments: "employee_id=42".into(), }; let ext = make_tool_extensions("get_compensation", &[]); - let (post_result, bg) = mgr.invoke::( - payload, ext, Some(result.context_table), - ).await; + let (post_result, bg) = mgr + .invoke::(payload, ext, Some(result.context_table)) + .await; print_result("get_compensation post-invoke", &post_result); bg.wait_for_background_tasks().await; @@ -352,9 +385,7 @@ async fn main() { arguments: "".into(), }; let ext = make_tool_extensions("list_departments", &[]); - let (result, bg) = mgr.invoke::( - payload, ext, None, - ).await; + let (result, bg) = mgr.invoke::(payload, ext, None).await; print_result("list_departments", &result); bg.wait_for_background_tasks().await; @@ -366,9 +397,7 @@ async fn main() { arguments: "foo=bar".into(), }; let ext = make_tool_extensions("some_other_tool", &[]); - let (result, bg) = mgr.invoke::( - payload, ext, None, - ).await; + let (result, bg) = mgr.invoke::(payload, ext, None).await; print_result("some_other_tool (wildcard)", &result); bg.wait_for_background_tasks().await; @@ -380,9 +409,7 @@ async fn main() { arguments: "".into(), }; let ext = make_tool_extensions("list_departments", &[]); - let (result, bg) = mgr.invoke::( - payload, ext, None, - ).await; + let (result, bg) = mgr.invoke::(payload, ext, None).await; print_result("list_departments (no user)", &result); bg.wait_for_background_tasks().await; diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs index a8e700d5..b2bad350 100644 --- a/crates/cpex-core/src/cmf/message.rs +++ b/crates/cpex-core/src/cmf/message.rs @@ -61,9 +61,7 @@ impl Message { Self { schema_version: super::constants::SCHEMA_VERSION.to_string(), role, - content: vec![ContentPart::Text { - text: text.into(), - }], + content: vec![ContentPart::Text { text: text.into() }], channel: None, } } diff --git a/crates/cpex-core/src/cmf/view.rs b/crates/cpex-core/src/cmf/view.rs index 3407b472..2c92e76d 100644 --- a/crates/cpex-core/src/cmf/view.rs +++ b/crates/cpex-core/src/cmf/view.rs @@ -82,15 +82,17 @@ impl ViewKind { ViewKind::PromptRequest => ViewAction::Invoke, ViewKind::PromptResult => ViewAction::Receive, // Direction-dependent kinds - ViewKind::Text | ViewKind::Thinking | ViewKind::Image - | ViewKind::Video | ViewKind::Audio | ViewKind::Document => { - match role { - Role::User => ViewAction::Send, - Role::Assistant => ViewAction::Generate, - Role::Tool => ViewAction::Receive, - Role::System | Role::Developer => ViewAction::Write, - } - } + ViewKind::Text + | ViewKind::Thinking + | ViewKind::Image + | ViewKind::Video + | ViewKind::Audio + | ViewKind::Document => match role { + Role::User => ViewAction::Send, + Role::Assistant => ViewAction::Generate, + Role::Tool => ViewAction::Receive, + Role::System | Role::Developer => ViewAction::Write, + }, } } @@ -210,12 +212,12 @@ impl<'a> MessageView<'a> { /// Whether this is a pre-execution hook (tool_pre_invoke, prompt_pre_fetch, etc.). pub fn is_pre(&self) -> bool { - self.hook.map_or(false, |h| h.contains("pre")) + self.hook.is_some_and(|h| h.contains("pre")) } /// Whether this is a post-execution hook. pub fn is_post(&self) -> bool { - self.hook.map_or(false, |h| h.contains("post")) + self.hook.is_some_and(|h| h.contains("post")) } // -- Universal properties -- @@ -225,7 +227,7 @@ impl<'a> MessageView<'a> { match self.part { ContentPart::Text { text } | ContentPart::Thinking { text } => Some(text), ContentPart::ToolResult { content: tr } => { - tr.content.as_str().map(|s| Some(s)).unwrap_or(None) + tr.content.as_str().map(Some).unwrap_or(None) } ContentPart::Resource { content: r } => r.content.as_deref(), ContentPart::PromptResult { content: pr } => pr.content.as_deref(), @@ -249,14 +251,10 @@ impl<'a> MessageView<'a> { /// URI for the entity. pub fn uri(&self) -> Option { match self.part { - ContentPart::ToolCall { content: tc } => { - Some(format!("tool://_/{}", tc.name)) - } + ContentPart::ToolCall { content: tc } => Some(format!("tool://_/{}", tc.name)), ContentPart::Resource { content: r } => Some(r.uri.clone()), ContentPart::ResourceRef { content: rr } => Some(rr.uri.clone()), - ContentPart::PromptRequest { content: pr } => { - Some(format!("prompt://_/{}", pr.name)) - } + ContentPart::PromptRequest { content: pr } => Some(format!("prompt://_/{}", pr.name)), _ => None, } } @@ -303,11 +301,21 @@ impl<'a> MessageView<'a> { // -- Type helpers -- - pub fn is_tool(&self) -> bool { self.kind.is_tool() } - pub fn is_resource(&self) -> bool { self.kind.is_resource() } - pub fn is_prompt(&self) -> bool { self.kind.is_prompt() } - pub fn is_media(&self) -> bool { self.kind.is_media() } - pub fn is_text(&self) -> bool { self.kind.is_text() } + pub fn is_tool(&self) -> bool { + self.kind.is_tool() + } + pub fn is_resource(&self) -> bool { + self.kind.is_resource() + } + pub fn is_prompt(&self) -> bool { + self.kind.is_prompt() + } + pub fn is_media(&self) -> bool { + self.kind.is_media() + } + pub fn is_text(&self) -> bool { + self.kind.is_text() + } // -- Extension accessors -- @@ -341,11 +349,7 @@ impl<'a> MessageView<'a> { /// Includes the view's properties, arguments, and optionally /// text content and extension context. Sensitive headers /// (Authorization, Cookie, X-API-Key) are stripped. - pub fn to_dict( - &self, - include_content: bool, - include_context: bool, - ) -> serde_json::Value { + pub fn to_dict(&self, include_content: bool, include_context: bool) -> serde_json::Value { use super::constants::*; let mut result = serde_json::Map::new(); @@ -417,7 +421,8 @@ impl<'a> MessageView<'a> { sub_map.insert(FIELD_TEAMS.into(), serde_json::json!(teams)); } if !sub_map.is_empty() { - ext_map.insert(FIELD_SUBJECT.into(), serde_json::Value::Object(sub_map)); + ext_map + .insert(FIELD_SUBJECT.into(), serde_json::Value::Object(sub_map)); } } @@ -540,9 +545,10 @@ pub fn iter_views<'a>( hook: Option<&'a str>, extensions: Option<&'a Extensions>, ) -> impl Iterator> { - message.content.iter().map(move |part| { - MessageView::new(part, message.role, hook, extensions) - }) + message + .content + .iter() + .map(move |part| MessageView::new(part, message.role, hook, extensions)) } // Also add iter_views to Message @@ -571,8 +577,12 @@ mod tests { schema_version: "2.0".into(), role: Role::Assistant, content: vec![ - ContentPart::Thinking { text: "Let me think...".into() }, - ContentPart::Text { text: "Here's the answer.".into() }, + ContentPart::Thinking { + text: "Let me think...".into(), + }, + ContentPart::Text { + text: "Here's the answer.".into(), + }, ContentPart::ToolCall { content: ToolCall { tool_call_id: "tc_001".into(), @@ -658,8 +668,8 @@ mod tests { let views: Vec<_> = msg.iter_views(None, None).collect(); assert_eq!(views[0].action(), ViewAction::Generate); // thinking from assistant assert_eq!(views[1].action(), ViewAction::Generate); // text from assistant - assert_eq!(views[2].action(), ViewAction::Execute); // tool call - assert_eq!(views[3].action(), ViewAction::Read); // resource + assert_eq!(views[2].action(), ViewAction::Execute); // tool call + assert_eq!(views[3].action(), ViewAction::Read); // resource } #[test] @@ -685,9 +695,9 @@ mod tests { fn test_view_type_helpers() { let msg = make_test_message(); let views: Vec<_> = msg.iter_views(None, None).collect(); - assert!(views[0].is_text()); // thinking - assert!(views[1].is_text()); // text - assert!(views[2].is_tool()); // tool call + assert!(views[0].is_text()); // thinking + assert!(views[1].is_text()); // text + assert!(views[2].is_tool()); // tool call assert!(views[3].is_resource()); // resource } @@ -700,8 +710,8 @@ mod tests { #[test] fn test_view_with_extensions() { + use crate::extensions::{HttpExtension, SecurityExtension}; use std::sync::Arc; - use crate::extensions::{SecurityExtension, HttpExtension}; let mut security = SecurityExtension::default(); security.add_label("PII"); @@ -766,10 +776,10 @@ mod tests { #[test] fn test_to_dict_with_extensions() { - use std::sync::Arc; use crate::extensions::{ - SecurityExtension, HttpExtension, RequestExtension, AgentExtension, + AgentExtension, HttpExtension, RequestExtension, SecurityExtension, }; + use std::sync::Arc; let mut security = SecurityExtension::default(); security.add_label("PII"); @@ -813,10 +823,16 @@ mod tests { // Subject visible assert_eq!(extensions["subject"]["id"], "alice"); - assert!(extensions["subject"]["roles"].as_array().unwrap().contains(&serde_json::json!("admin"))); + assert!(extensions["subject"]["roles"] + .as_array() + .unwrap() + .contains(&serde_json::json!("admin"))); // Labels visible - assert!(extensions["labels"].as_array().unwrap().contains(&serde_json::json!("PII"))); + assert!(extensions["labels"] + .as_array() + .unwrap() + .contains(&serde_json::json!("PII"))); // Environment visible assert_eq!(extensions["environment"], "production"); diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 375094e5..89d962a2 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::error::PluginError; use crate::plugin::PluginConfig; @@ -100,6 +100,18 @@ pub struct PluginSettings { /// Whether to halt the pipeline on any plugin error. #[serde(default)] pub fail_on_plugin_error: bool, + + /// Maximum number of entries in the routing cache. + /// + /// When the cache reaches this size, new resolutions are computed + /// normally but not memoized — the cache rejects further inserts + /// and emits a warning. This bounds memory growth from + /// attacker-controlled entity names without the reasoning hazards + /// of eviction (silently dropped entries, stale-vs-current + /// confusion). Operators see the warning and tune the cap or + /// investigate the entity-name growth. + #[serde(default = "default_route_cache_max_entries")] + pub route_cache_max_entries: usize, } impl Default for PluginSettings { @@ -110,10 +122,15 @@ impl Default for PluginSettings { short_circuit_on_deny: true, parallel_execution_within_band: false, fail_on_plugin_error: false, + route_cache_max_entries: default_route_cache_max_entries(), } } } +fn default_route_cache_max_entries() -> usize { + 10_000 +} + fn default_timeout() -> u64 { 30 } @@ -163,7 +180,7 @@ pub struct PolicyGroup { /// Plugin references to activate when this group matches. #[serde(default)] - pub plugins: Vec, + pub plugins: Vec, } // --------------------------------------------------------------------------- @@ -181,14 +198,14 @@ pub struct PolicyGroup { /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] -pub enum PluginRef { +pub enum PluginRouteRef { /// Just the name — activate the plugin with no config overrides. Name(String), /// Name with config overrides — single-key map. WithOverrides(HashMap), } -impl PluginRef { +impl PluginRouteRef { /// Extract the plugin name from this reference. pub fn name(&self) -> &str { match self { @@ -244,7 +261,7 @@ pub struct RouteEntry { /// Plugin references to activate for this route. #[serde(default)] - pub plugins: Vec, + pub plugins: Vec, } // --------------------------------------------------------------------------- @@ -272,19 +289,82 @@ pub struct RouteMeta { // String or List (for tool matching) // --------------------------------------------------------------------------- +/// An entity-name pattern. Holds the original pattern string (for +/// serialization round-tripping and operator-facing diagnostics) plus a +/// `WildMatch` matcher pre-compiled at deserialize time so route resolution +/// doesn't re-parse the pattern on every request. Custom `Serialize` / +/// `Deserialize` make this transparent to YAML — it serializes as a plain +/// string, just like the previous `String` field did. +/// +/// Glob syntax (via `wildmatch`): +/// - `*` matches any sequence of characters (including empty). +/// - `?` matches any single character. +/// +/// The previous hand-rolled matcher only handled trailing-`*` correctly: +/// `*suffix` patterns silently matched almost nothing, and multi-star +/// patterns like `**` accidentally matched everything. Both shapes are +/// real security footguns for scope/tool restriction rules — switching to +/// `wildmatch` gives us full single-segment glob semantics. +#[derive(Debug, Clone)] +pub struct Pattern { + pattern: String, + matcher: wildmatch::WildMatch, +} + +impl Pattern { + /// Compile a pattern. Done once at config load; subsequent `matches()` + /// calls reuse the compiled `WildMatch`. + pub fn new(pattern: impl Into) -> Self { + let pattern = pattern.into(); + let matcher = wildmatch::WildMatch::new(&pattern); + Self { pattern, matcher } + } + + /// Match the given name against the compiled pattern. + pub fn matches(&self, name: &str) -> bool { + self.matcher.matches(name) + } + + /// The original pattern string (e.g., `"hr-*"`). + pub fn as_str(&self) -> &str { + &self.pattern + } +} + +impl Default for Pattern { + fn default() -> Self { + Self::new("") + } +} + +impl Serialize for Pattern { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.pattern) + } +} + +impl<'de> Deserialize<'de> for Pattern { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(Pattern::new(s)) + } +} + /// A tool matcher — single name, list of names, or glob pattern. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum StringOrList { - /// Single string (exact name or glob pattern). - Single(String), + /// Single string (exact name or glob pattern). Pre-compiled at + /// deserialize time so the route-resolution slow path doesn't re-parse + /// on each request. + Single(Pattern), /// List of exact names. List(Vec), } impl Default for StringOrList { fn default() -> Self { - Self::Single(String::new()) + Self::Single(Pattern::default()) } } @@ -292,16 +372,7 @@ impl StringOrList { /// Check if this matcher matches the given name. pub fn matches(&self, name: &str) -> bool { match self { - Self::Single(pattern) => { - if pattern == "*" { - true - } else if pattern.contains('*') { - let prefix = pattern.trim_end_matches('*'); - name.starts_with(prefix) - } else { - name == pattern - } - } + Self::Single(pattern) => pattern.matches(name), Self::List(names) => names.iter().any(|n| n == name), } } @@ -312,7 +383,7 @@ impl StringOrList { // --------------------------------------------------------------------------- /// Load and parse a CPEX config from a YAML file. -pub fn load_config(path: &Path) -> Result { +pub fn load_config(path: &Path) -> Result> { let content = std::fs::read_to_string(path).map_err(|e| PluginError::Config { message: format!("failed to read config file '{}': {}", path.display(), e), })?; @@ -320,11 +391,10 @@ pub fn load_config(path: &Path) -> Result { } /// Parse a CPEX config from a YAML string. -pub fn parse_config(yaml: &str) -> Result { - let config: CpexConfig = - serde_yaml::from_str(yaml).map_err(|e| PluginError::Config { - message: format!("failed to parse config YAML: {}", e), - })?; +pub fn parse_config(yaml: &str) -> Result> { + let config: CpexConfig = serde_yaml::from_str(yaml).map_err(|e| PluginError::Config { + message: format!("failed to parse config YAML: {}", e), + })?; validate_config(&config)?; Ok(config) } @@ -334,19 +404,18 @@ pub fn parse_config(yaml: &str) -> Result { // --------------------------------------------------------------------------- /// Validate a parsed config for structural correctness. -fn validate_config(config: &CpexConfig) -> Result<(), PluginError> { +fn validate_config(config: &CpexConfig) -> Result<(), Box> { let mut seen_names = HashSet::new(); for plugin in &config.plugins { if !seen_names.insert(&plugin.name) { - return Err(PluginError::Config { + return Err(Box::new(PluginError::Config { message: format!("duplicate plugin name: '{}'", plugin.name), - }); + })); } } if config.routing_enabled() { - let plugin_names: HashSet<&str> = - config.plugins.iter().map(|p| p.name.as_str()).collect(); + let plugin_names: HashSet<&str> = config.plugins.iter().map(|p| p.name.as_str()).collect(); for (i, route) in config.routes.iter().enumerate() { let count = [ @@ -360,24 +429,31 @@ fn validate_config(config: &CpexConfig) -> Result<(), PluginError> { .count(); if count == 0 { - return Err(PluginError::Config { + return Err(Box::new(PluginError::Config { message: format!( "route {} has no entity matcher (need tool, resource, prompt, or llm)", i ), - }); + })); } if count > 1 { - return Err(PluginError::Config { - message: format!("route {} has multiple entity matchers (need exactly one)", i), - }); + return Err(Box::new(PluginError::Config { + message: format!( + "route {} has multiple entity matchers (need exactly one)", + i + ), + })); } for plugin_ref in &route.plugins { if !plugin_names.contains(plugin_ref.name()) { - return Err(PluginError::Config { - message: format!("route {} references unknown plugin '{}'", i, plugin_ref.name()), - }); + return Err(Box::new(PluginError::Config { + message: format!( + "route {} references unknown plugin '{}'", + i, + plugin_ref.name() + ), + })); } } } @@ -385,13 +461,13 @@ fn validate_config(config: &CpexConfig) -> Result<(), PluginError> { for (group_name, group) in &config.global.policies { for plugin_ref in &group.plugins { if !plugin_names.contains(plugin_ref.name()) { - return Err(PluginError::Config { + return Err(Box::new(PluginError::Config { message: format!( "policy group '{}' references unknown plugin '{}'", group_name, plugin_ref.name() ), - }); + })); } } } @@ -411,6 +487,24 @@ const SPECIFICITY_GLOB: usize = 300; const SPECIFICITY_WHEN_ONLY: usize = 10; const SPECIFICITY_WILDCARD: usize = 0; +/// Score a single entity matcher (tool / resource / prompt / llm) against +/// a request entity name, returning the specificity bucket if it matches +/// or `None` if it doesn't (or the matcher is absent). Replaces four +/// copy-pasted match arms in `resolve_plugins_for_entity`. +fn score_entity_match(matcher: Option<&StringOrList>, entity_name: &str) -> Option { + let matcher = matcher?; + if !matcher.matches(entity_name) { + return None; + } + let score = match matcher { + StringOrList::Single(p) if p.as_str() == "*" => SPECIFICITY_WILDCARD, + StringOrList::Single(p) if p.as_str().contains('*') => SPECIFICITY_GLOB, + StringOrList::List(_) => SPECIFICITY_NAME_LIST, + StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, + }; + Some(score) +} + /// Resolve which plugins should fire for a given entity. /// /// When routing is disabled, returns all plugin names. When enabled, @@ -502,7 +596,7 @@ pub struct ResolvedPlugin { /// Collect plugin refs into the resolved list. fn collect_plugin_refs( - refs: &[PluginRef], + refs: &[PluginRouteRef], resolved: &mut Vec, route_when: Option<&str>, ) { @@ -531,79 +625,31 @@ fn find_matching_route<'a>( // Check scope compatibility let route_scope = route.meta.as_ref().and_then(|m| m.scope.as_deref()); let scope_bonus = match (route_scope, request_scope) { - (None, _) => 0, // route is global - (Some(rs), Some(rq)) if rs == rq => 100, // scopes match - (Some(_), _) => continue, // scope mismatch — skip + (None, _) => 0, // route is global + (Some(rs), Some(rq)) if rs == rq => 100, // scopes match + (Some(_), _) => continue, // scope mismatch — skip }; - let base_specificity = match entity_type { - "tool" => { - if let Some(matcher) = &route.tool { - if !matcher.matches(entity_name) { - continue; - } - match matcher { - StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, - StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, - StringOrList::List(_) => SPECIFICITY_NAME_LIST, - StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, - } - } else { - continue; - } - } - "resource" => { - if let Some(matcher) = &route.resource { - if !matcher.matches(entity_name) { - continue; - } - match matcher { - StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, - StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, - StringOrList::List(_) => SPECIFICITY_NAME_LIST, - StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, - } - } else { - continue; - } - } - "prompt" => { - if let Some(matcher) = &route.prompt { - if !matcher.matches(entity_name) { - continue; - } - match matcher { - StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, - StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, - StringOrList::List(_) => SPECIFICITY_NAME_LIST, - StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, - } - } else { - continue; - } - } - "llm" => { - if let Some(matcher) = &route.llm { - if !matcher.matches(entity_name) { - continue; - } - match matcher { - StringOrList::Single(s) if s == "*" => SPECIFICITY_WILDCARD, - StringOrList::Single(s) if s.contains('*') => SPECIFICITY_GLOB, - StringOrList::List(_) => SPECIFICITY_NAME_LIST, - StringOrList::Single(_) => SPECIFICITY_EXACT_NAME, - } - } else { - continue; - } - } + let entity_matcher = match entity_type { + "tool" => route.tool.as_ref(), + "resource" => route.resource.as_ref(), + "prompt" => route.prompt.as_ref(), + "llm" => route.llm.as_ref(), _ => continue, }; + let base_specificity = match score_entity_match(entity_matcher, entity_name) { + Some(score) => score, + None => continue, + }; - let when_bonus = if route.when.is_some() { SPECIFICITY_WHEN_ONLY } else { 0 }; + let when_bonus = if route.when.is_some() { + SPECIFICITY_WHEN_ONLY + } else { + 0 + }; let total = base_specificity + scope_bonus + when_bonus; - if best.map_or(true, |(s, _)| total > s) { + if best.is_none_or(|(s, _)| total > s) { best = Some((total, route)); } } @@ -803,7 +849,8 @@ routes: tags: [pii] "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); assert!(names.contains(&"identity")); assert!(names.contains(&"apl_policy")); @@ -827,7 +874,8 @@ routes: - tool: get_compensation "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags()); + let resolved = + resolve_plugins_for_entity(&config, "tool", "unknown_tool", None, &no_tags()); let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); assert_eq!(names, vec!["identity"]); } @@ -853,7 +901,8 @@ routes: - specific "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags()); + let resolved = + resolve_plugins_for_entity(&config, "tool", "hr-compensation", None, &no_tags()); let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); assert!(names.contains(&"specific")); assert!(!names.contains(&"general")); @@ -874,7 +923,8 @@ routes: - rate_limiter "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); assert_eq!(resolved[0].name, "rate_limiter"); assert!(resolved[0].config_overrides.is_none()); } @@ -898,7 +948,8 @@ routes: max_requests: 10 "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); assert_eq!(resolved[0].name, "rate_limiter"); assert!(resolved[0].config_overrides.is_some()); let overrides = resolved[0].config_overrides.as_ref().unwrap(); @@ -926,7 +977,8 @@ routes: sensitivity: high "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); assert_eq!(resolved.len(), 2); assert_eq!(resolved[0].name, "rate_limiter"); assert!(resolved[0].config_overrides.is_none()); @@ -961,23 +1013,100 @@ routes: tags: [pii] "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); assert_eq!(names, vec!["a", "b", "c"]); } #[test] - fn test_glob_matches() { - let matcher = StringOrList::Single("hr-*".to_string()); + fn test_glob_trailing_wildcard() { + let matcher = StringOrList::Single(Pattern::new("hr-*")); assert!(matcher.matches("hr-compensation")); assert!(matcher.matches("hr-benefits")); + assert!(matcher.matches("hr-")); // empty match for * assert!(!matcher.matches("finance-report")); + assert!(!matcher.matches("hr")); } #[test] fn test_wildcard_matches_everything() { - let matcher = StringOrList::Single("*".to_string()); + let matcher = StringOrList::Single(Pattern::new("*")); assert!(matcher.matches("anything")); + assert!(matcher.matches("")); + } + + /// Regression for the security footgun: `*suffix` patterns were + /// silently matching almost nothing because the previous matcher + /// looked for `"*suffix"` as a literal prefix. + #[test] + fn test_glob_leading_wildcard() { + let matcher = StringOrList::Single(Pattern::new("*-prod")); + assert!(matcher.matches("foo-prod")); + assert!(matcher.matches("-prod")); // empty match for * + assert!(!matcher.matches("foo-staging")); + assert!(!matcher.matches("prod")); + } + + /// Regression for `prefix*suffix` patterns also broken before. + #[test] + fn test_glob_mid_wildcard() { + let matcher = StringOrList::Single(Pattern::new("hr-*-v1")); + assert!(matcher.matches("hr-comp-v1")); + assert!(matcher.matches("hr--v1")); // empty match for * + assert!(!matcher.matches("hr-comp-v2")); + assert!(!matcher.matches("finance-comp-v1")); + } + + /// Multiple-wildcard patterns must work everywhere `*` appears. + #[test] + fn test_glob_multiple_wildcards() { + let matcher = StringOrList::Single(Pattern::new("*hr*comp*")); + assert!(matcher.matches("hr-comp")); + assert!(matcher.matches("xyz-hr-comp-foo")); + assert!(!matcher.matches("hr-only")); + assert!(!matcher.matches("comp-only")); + } + + /// Regression for the OTHER security footgun: multi-star patterns + /// like `**` were `trim_end_matches('*')`'d to `""` and then matched + /// every name via `starts_with("")`. With wildmatch this is a + /// degenerate-but-correct "match anything" pattern, equivalent to `*`. + #[test] + fn test_glob_multi_star_is_equivalent_to_single_star() { + for pattern in &["**", "***", "*****"] { + let matcher = StringOrList::Single(Pattern::new(*pattern)); + assert!( + matcher.matches("anything"), + "pattern {} should match", + pattern + ); + assert!( + matcher.matches(""), + "pattern {} should match empty", + pattern + ); + } + } + + /// `WildMatch` is built once at deserialize / `Pattern::new` time and + /// reused; this test just sanity-checks the round-trip through serde. + #[test] + fn test_pattern_round_trips_through_yaml() { + let yaml = "tool: '*-prod'"; + #[derive(Deserialize, Serialize)] + struct Wrap { + tool: StringOrList, + } + let parsed: Wrap = serde_yaml::from_str(yaml).unwrap(); + assert!(parsed.tool.matches("foo-prod")); + assert!(!parsed.tool.matches("foo-staging")); + let back = serde_yaml::to_string(&parsed).unwrap(); + assert!( + back.contains("*-prod"), + "serialized YAML should preserve pattern: {}", + back + ); } #[test] @@ -1034,23 +1163,30 @@ routes: // With matching scope — scoped route wins (more specific) let resolved = resolve_plugins_for_entity( - &config, "tool", "get_compensation", Some("hr-services"), &no_tags(), + &config, + "tool", + "get_compensation", + Some("hr-services"), + &no_tags(), ); let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); assert!(names.contains(&"scoped_plugin")); assert!(!names.contains(&"global_plugin")); // Without scope — global route matches - let resolved = resolve_plugins_for_entity( - &config, "tool", "get_compensation", None, &no_tags(), - ); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); assert!(names.contains(&"global_plugin")); assert!(!names.contains(&"scoped_plugin")); // With different scope — global route matches (scoped doesn't) let resolved = resolve_plugins_for_entity( - &config, "tool", "get_compensation", Some("billing"), &no_tags(), + &config, + "tool", + "get_compensation", + Some("billing"), + &no_tags(), ); let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); assert!(names.contains(&"global_plugin")); @@ -1088,9 +1224,8 @@ routes: let mut host_tags = HashSet::new(); host_tags.insert("runtime_tag".to_string()); - let resolved = resolve_plugins_for_entity( - &config, "tool", "get_compensation", None, &host_tags, - ); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &host_tags); let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); // Both route's static tag (pii) and host's runtime tag activate their groups @@ -1116,11 +1251,13 @@ routes: - conditional_plugin "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity( - &config, "tool", "get_compensation", None, &no_tags(), - ); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); assert_eq!(resolved[0].name, "conditional_plugin"); - assert_eq!(resolved[0].when.as_deref(), Some("args.include_ssn == true")); + assert_eq!( + resolved[0].when.as_deref(), + Some("args.include_ssn == true") + ); } #[test] @@ -1146,9 +1283,8 @@ routes: - route_plugin "#; let config = parse_config(yaml).unwrap(); - let resolved = resolve_plugins_for_entity( - &config, "tool", "get_compensation", None, &no_tags(), - ); + let resolved = + resolve_plugins_for_entity(&config, "tool", "get_compensation", None, &no_tags()); // global_plugin has no when clause (from all group) let global = resolved.iter().find(|r| r.name == "global_plugin").unwrap(); diff --git a/crates/cpex-core/src/context.rs b/crates/cpex-core/src/context.rs index 59e61a8a..2176c7bc 100644 --- a/crates/cpex-core/src/context.rs +++ b/crates/cpex-core/src/context.rs @@ -23,6 +23,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +use uuid::Uuid; // --------------------------------------------------------------------------- // Plugin Context @@ -107,13 +108,88 @@ impl Default for PluginContext { // Plugin Context Table // --------------------------------------------------------------------------- -/// Lookup table of `PluginContext` instances indexed by plugin ID. +/// Threaded execution state carried from one hook invocation to the next +/// within a single request lifecycle (e.g., `pre_invoke` → `post_invoke`). /// -/// Threaded across hook invocations so that a plugin's `local_state` -/// persists from one hook to the next within the same request lifecycle -/// (e.g., `pre_invoke` → `post_invoke`). +/// The table holds the canonical pipeline state in two parts: /// -/// The caller receives the table back in `PipelineResult` and passes -/// it into the next hook invocation. On the first hook call, pass -/// `None` — the executor creates fresh contexts for each plugin. -pub type PluginContextTable = HashMap; +/// - `global_state` — a single shared map across all plugins. The executor +/// clones this into each plugin's `PluginContext.global_state` at the +/// start of a run, then commits the plugin's possibly-modified copy back +/// when the run completes (last-writer-wins for serial phases). +/// - `local_states` — per-plugin private state, indexed by plugin ID. +/// Persists across hook invocations so a plugin's `pre_invoke` can stash +/// data its `post_invoke` will read. +/// +/// Storing `global_state` once (rather than copying it inside every per-plugin +/// `PluginContext`) makes the canonical state explicit and removes the +/// non-deterministic "pick an arbitrary plugin's snapshot" pattern that was +/// previously needed to recover it. +/// +/// Returned by the executor in `PipelineResult` and passed back into the +/// next hook call. On the first hook call pass `None` — the executor +/// creates a fresh table. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct PluginContextTable { + /// Authoritative shared state across all plugins in the pipeline. + #[serde(default)] + pub global_state: HashMap, + + /// Per-plugin local state, indexed by plugin ID (`Uuid`). + #[serde(default)] + pub local_states: HashMap>, +} + +impl PluginContextTable { + /// Create an empty context table. + pub fn new() -> Self { + Self::default() + } + + /// Build a `PluginContext` for the given plugin, *removing* its stored + /// local_state from the table and seeding it with a fresh clone of the + /// canonical global_state. Use in serial phases where the plugin will + /// commit its local_state changes back via [`store_context`]. + /// + /// If the plugin has no stored local_state yet, its context starts + /// empty (first invocation in the request lifecycle). + pub fn take_context(&mut self, plugin_id: Uuid) -> PluginContext { + PluginContext { + local_state: self.local_states.remove(&plugin_id).unwrap_or_default(), + global_state: self.global_state.clone(), + } + } + + /// Build a `PluginContext` for the given plugin without mutating the + /// table — the local_state is *cloned* and the global_state is cloned. + /// Use in read-only phases (audit, concurrent, fire-and-forget) where + /// per-plugin mutations should not influence subsequent plugins. + pub fn snapshot_context(&self, plugin_id: Uuid) -> PluginContext { + PluginContext { + local_state: self + .local_states + .get(&plugin_id) + .cloned() + .unwrap_or_default(), + global_state: self.global_state.clone(), + } + } + + /// Commit a plugin's context back into the table after it ran. Replaces + /// the canonical global_state with the plugin's possibly-modified copy + /// (move, no clone) and stores the plugin's local_state for next time. + pub fn store_context(&mut self, plugin_id: Uuid, ctx: PluginContext) { + self.global_state = ctx.global_state; + self.local_states.insert(plugin_id, ctx.local_state); + } + + /// Number of plugins with stored local_state in the table. + pub fn len(&self) -> usize { + self.local_states.len() + } + + /// Whether the table holds no per-plugin local_state. + pub fn is_empty(&self) -> bool { + self.local_states.is_empty() + } +} diff --git a/crates/cpex-core/src/error.rs b/crates/cpex-core/src/error.rs index fd253429..576dc5bc 100644 --- a/crates/cpex-core/src/error.rs +++ b/crates/cpex-core/src/error.rs @@ -75,6 +75,124 @@ pub enum PluginError { UnknownHook { hook_type: String }, } +impl PluginError { + /// Box this error for use in `Result>`. + /// + /// Public APIs return `Result>` rather than + /// `Result>` because the enum is large (~184 bytes + /// — `details: HashMap` and the `source: Box` push it + /// well past clippy's `result_large_err` threshold). Boxing keeps + /// `Result` pointer-sized on the success path; the + /// allocation only happens on the error path. + /// + /// `.boxed()` is sugar for `Box::new(...)` that reads better at + /// construction sites: `PluginError::Config { ... }.boxed()`. + /// `?` already calls `From::from`, and `From for Box` is + /// built into std, so existing `?` chains keep working. + pub fn boxed(self) -> Box { + Box::new(self) + } +} + +// --------------------------------------------------------------------------- +// Plugin Error Record +// --------------------------------------------------------------------------- + +/// A `Clone`-able, serialization-friendly snapshot of a `PluginError`. +/// +/// Used in `PipelineResult.errors` to surface execution failures from +/// `on_error: ignore` / `on_error: disable` plugins to the caller — +/// previously those errors were only logged via `tracing::warn!` and +/// were invisible to programmatic consumers (agents, dashboards, +/// retry logic). +/// +/// `PluginError` itself can't be `Clone` because of its +/// `Box` source field, and that +/// field doesn't survive serialization anyway. `PluginErrorRecord` +/// flattens the five enum variants into a single shape — the +/// `From<&PluginError>` impl handles the variant-to-fields mapping. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginErrorRecord { + pub plugin_name: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub details: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proto_error_code: Option, +} + +/// Forward `&Box` to the `&PluginError` impl. +/// +/// Public APIs return `Result>` (see +/// `PluginError::boxed`), which means error-handling code in the +/// pipeline (e.g., `Ok(Err(e))` inside `executor::run_*_phase`) holds +/// `e: Box`. This blanket forward keeps existing +/// `(&e).into()` call sites working without forcing every caller to +/// write `(&*e).into()` after the boxing migration. +impl From<&Box> for PluginErrorRecord { + fn from(e: &Box) -> Self { + PluginErrorRecord::from(e.as_ref()) + } +} + +impl From<&PluginError> for PluginErrorRecord { + fn from(e: &PluginError) -> Self { + match e { + PluginError::Execution { + plugin_name, + message, + code, + details, + proto_error_code, + .. + } => Self { + plugin_name: plugin_name.clone(), + message: message.clone(), + code: code.clone(), + details: details.clone(), + proto_error_code: *proto_error_code, + }, + PluginError::Timeout { + plugin_name, + timeout_ms, + proto_error_code, + } => Self { + plugin_name: plugin_name.clone(), + message: format!("plugin timed out after {}ms", timeout_ms), + code: Some("timeout".into()), + details: HashMap::new(), + proto_error_code: *proto_error_code, + }, + PluginError::Violation { + plugin_name, + violation, + } => Self { + plugin_name: plugin_name.clone(), + message: format!("plugin denied: {}", violation.reason), + code: Some(violation.code.clone()), + details: violation.details.clone(), + proto_error_code: violation.proto_error_code, + }, + PluginError::Config { message } => Self { + plugin_name: String::new(), + message: message.clone(), + code: Some("config".into()), + details: HashMap::new(), + proto_error_code: None, + }, + PluginError::UnknownHook { hook_type } => Self { + plugin_name: String::new(), + message: format!("unknown hook type: {}", hook_type), + code: Some("unknown_hook".into()), + details: HashMap::new(), + proto_error_code: None, + }, + } + } +} + // --------------------------------------------------------------------------- // Plugin Violations // --------------------------------------------------------------------------- diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index 20a80a86..ddf4247a 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -25,7 +25,6 @@ // cpex/framework/manager.py. use std::any::Any; -use std::collections::HashMap; use std::fmt; use std::sync::Arc; use std::time::Duration; @@ -33,7 +32,8 @@ use std::time::Duration; use tokio::time::timeout; use tracing::{error, warn}; -use crate::context::{PluginContext, PluginContextTable}; +use crate::context::PluginContextTable; +use crate::error::PluginError; use crate::extensions::filter_extensions; use crate::hooks::payload::{Extensions, PluginPayload, WriteToken}; use crate::plugin::OnError; @@ -95,6 +95,15 @@ pub struct PipelineResult { /// The violation that caused a deny, if any. pub violation: Option, + /// Errors from plugins that ran with `on_error: ignore` or + /// `on_error: disable`. These plugins didn't halt the pipeline + /// (their on_error policy said to continue), but the caller + /// should still know the errors happened so it can log them in + /// a structured way, retry the affected plugin, or alert. + /// Empty when no plugin errored on a non-halt path. + /// Fire-and-forget errors live in `BackgroundTasks` instead. + pub errors: Vec, + /// Optional metadata aggregated from plugins (telemetry, diagnostics). pub metadata: Option, @@ -115,6 +124,7 @@ impl PipelineResult { modified_payload: Some(payload), modified_extensions: Some(extensions), violation: None, + errors: Vec::new(), metadata: None, context_table, } @@ -131,11 +141,20 @@ impl PipelineResult { modified_payload: None, modified_extensions: Some(extensions), violation: Some(violation), + errors: Vec::new(), metadata: None, context_table, } } + /// Replace the errors vec on a constructed PipelineResult. Used by + /// the executor to attach errors collected from `on_error: ignore` + /// / `on_error: disable` plugins. + pub fn with_errors(mut self, errors: Vec) -> Self { + self.errors = errors; + self + } + /// Whether this result represents a denial. pub fn is_denied(&self) -> bool { !self.continue_processing @@ -228,6 +247,7 @@ impl fmt::Debug for BackgroundTasks { /// /// The executor is stateless — all state comes from the arguments. /// One executor instance can serve multiple concurrent hook invocations. +#[derive(Clone)] pub struct Executor { config: ExecutorConfig, } @@ -262,6 +282,7 @@ impl Executor { payload: Box, extensions: Extensions, context_table: Option, + task_tracker: &tokio_util::task::TaskTracker, ) -> (PipelineResult, BackgroundTasks) { let mut ctx_table = context_table.unwrap_or_default(); @@ -273,11 +294,16 @@ impl Executor { } // Group entries by mode (from trusted_config) - let (sequential, transform, audit, concurrent, fire_and_forget) = - group_by_mode(entries); + let (sequential, transform, audit, concurrent, fire_and_forget) = group_by_mode(entries); let mut current_payload = payload; let mut current_extensions = extensions; + // Accumulator for errors from `on_error: ignore` / `on_error: + // disable` plugins across all phases. Surfaced to the caller + // via `PipelineResult.errors` so swallowed failures stay + // observable. Halt-condition errors (Fail, deny) skip this and + // become the violation directly. + let mut errors: Vec = Vec::new(); // Phase 1: SEQUENTIAL — serial, chained, can block + modify if let Some(v) = self @@ -286,14 +312,15 @@ impl Executor { &mut current_payload, &mut current_extensions, &mut ctx_table, - true, // can_block - true, // can_modify + true, // can_block + true, // can_modify "SEQUENTIAL", + &mut errors, ) .await { return ( - PipelineResult::denied(v, current_extensions, ctx_table), + PipelineResult::denied(v, current_extensions, ctx_table).with_errors(errors), BackgroundTasks::empty(), ); } @@ -308,34 +335,53 @@ impl Executor { false, // can_block true, // can_modify "TRANSFORM", + &mut errors, ) .await; // Phase 3: AUDIT — serial, read-only, discard results - self.run_ref_phase(&audit, &*current_payload, ¤t_extensions, &ctx_table, "AUDIT") - .await; + self.run_ref_phase( + &audit, + &*current_payload, + ¤t_extensions, + &ctx_table, + "AUDIT", + &mut errors, + ) + .await; // Phase 4: CONCURRENT — parallel, can block, cannot modify if let Some(violation) = self - .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, &ctx_table) + .run_concurrent_phase( + &concurrent, + &*current_payload, + ¤t_extensions, + &ctx_table, + &mut errors, + ) .await { return ( - PipelineResult::denied(violation, current_extensions, ctx_table), + PipelineResult::denied(violation, current_extensions, ctx_table) + .with_errors(errors), BackgroundTasks::empty(), ); } - // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results + // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results. + // FAF errors don't go in PipelineResult.errors — they're delivered + // via BackgroundTasks::wait_for_background_tasks() instead. let bg_handles = self.spawn_fire_and_forget( &fire_and_forget, &*current_payload, ¤t_extensions, &ctx_table, + task_tracker, ); ( - PipelineResult::allowed_with(current_payload, current_extensions, ctx_table), + PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) + .with_errors(errors), BackgroundTasks::from_handles(bg_handles), ) } @@ -354,6 +400,7 @@ impl Executor { /// Each plugin's context is looked up in the context table (preserving /// `local_state` from previous hooks) or created fresh. After execution, /// `global_state` changes are merged back so the next plugin sees them. + #[allow(clippy::too_many_arguments)] // internal phase helper — args have distinct types and meaning async fn run_serial_phase( &self, entries: &[HookEntry], @@ -363,25 +410,22 @@ impl Executor { can_block: bool, can_modify: bool, phase_label: &str, + errors: &mut Vec, ) -> Option { - // Extract current global state from the table (use last plugin's - // global_state, or start empty). We maintain a running copy that - // gets set on each plugin's context and merged back after. - let mut global_state = ctx_table - .values() - .last() - .map(|c| c.global_state.clone()) - .unwrap_or_default(); - for entry in entries { - let plugin_name = entry.plugin_ref.name().to_string(); - let plugin_id = entry.plugin_ref.id().to_string(); + // Borrow names/ids on the happy path — allocate only when + // building a violation or stashing the local_state back into + // the table. Previously `name.to_string()` + `id.to_string()` + // ran unconditionally on every plugin per invoke. + let plugin_name = entry.plugin_ref.name(); + let plugin_id = entry.plugin_ref.id(); let on_error = entry.plugin_ref.trusted_config().on_error; - // Look up existing context (preserves local_state from prior hooks) - // or create a fresh one. Set global_state to the current running copy. - let mut ctx = ctx_table.remove(&plugin_id).unwrap_or_default(); - ctx.global_state = global_state.clone(); + // Take this plugin's context out of the table — pulls its stored + // local_state and seeds global_state from the canonical store. + // Replaces the previous values().last() seed, which was + // non-deterministic across HashMap iteration orders. + let mut ctx = ctx_table.take_context(plugin_id); // Filter extensions per plugin based on declared capabilities. // Produces a filtered view with None for ungated slots. @@ -408,8 +452,11 @@ impl Executor { // Execute with timeout — handler borrows payload, gets filtered extensions let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - let result = timeout(timeout_dur, entry.handler.invoke(&**payload, &filtered, &mut ctx)) - .await; + let result = timeout( + timeout_dur, + entry.handler.invoke(&**payload, &filtered, &mut ctx), + ) + .await; match result { Ok(Ok(result_box)) => { @@ -417,7 +464,7 @@ impl Executor { // Check deny if !erased.continue_processing && can_block { if let Some(mut v) = erased.violation { - v.plugin_name = Some(plugin_name.clone()); + v.plugin_name = Some(plugin_name.to_string()); return Some(v); } } @@ -429,15 +476,22 @@ impl Executor { } if let Some(owned) = erased.modified_extensions { // Validate tier constraints before accepting - if !extensions.validate_immutable(&owned) { + let valid = extensions.validate_immutable(&owned); + if !valid { warn!( "{} plugin '{}' violated immutable tier — \ modified an immutable extension slot. \ Extension changes rejected.", phase_label, plugin_name ); - } else if let Some(ref orig_sec) = extensions.security { - if let Some(ref new_sec) = owned.security { + } else if capabilities.contains("read_labels") { + // Only enforce monotonic labels if the plugin + // could see them. A plugin without read_labels + // has empty labels in its filtered view — that's + // not a removal. + if let (Some(ref orig_sec), Some(ref new_sec)) = + (&extensions.security, &owned.security) + { if !new_sec.labels.is_superset(&orig_sec.labels) { warn!( "{} plugin '{}' violated monotonic tier — \ @@ -457,60 +511,89 @@ impl Executor { } } - // Merge global state changes back from the handler. - // The handler received &mut PluginContext and may have - // written to ctx.global_state directly. - if ctx.global_state != global_state { - global_state = ctx.global_state.clone(); - } + // Plugin writes to ctx.global_state are committed back + // to the canonical store via store_context() below. } // If extract failed or no modifications — payload unchanged } Ok(Err(e)) => { error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e); match on_error { - OnError::Fail => { + OnError::Fail if can_block => { let mut v = crate::error::PluginViolation::new( "plugin_error", format!("Plugin '{}' failed: {}", plugin_name, e), ); - v.plugin_name = Some(plugin_name); + v.plugin_name = Some(plugin_name.to_string()); return Some(v); } - OnError::Ignore => {} + // Any non-halt outcome (Fail-in-non-blocking-phase, + // Ignore, Disable): record the error so the caller + // sees it in PipelineResult.errors instead of + // having to read the warn-log. + OnError::Fail => { + warn!( + "{} plugin '{}' on_error=fail in non-blocking phase — not halting", + phase_label, plugin_name, + ); + errors.push((&e).into()); + } + OnError::Ignore => { + errors.push((&e).into()); + } OnError::Disable => { - warn!("{} plugin '{}' disabled after error", phase_label, plugin_name); + warn!( + "{} plugin '{}' disabled after error", + phase_label, plugin_name + ); + errors.push((&e).into()); entry.plugin_ref.disable(); } } } Err(_) => { error!("{} plugin '{}' timed out", phase_label, plugin_name); + let timeout_err = crate::error::PluginError::Timeout { + plugin_name: plugin_name.to_string(), + timeout_ms: timeout_dur.as_millis() as u64, + proto_error_code: None, + }; match on_error { - OnError::Fail => { + OnError::Fail if can_block => { let mut v = crate::error::PluginViolation::new( "plugin_timeout", format!("Plugin '{}' timed out", plugin_name), ); - v.plugin_name = Some(plugin_name); + v.plugin_name = Some(plugin_name.to_string()); return Some(v); } - OnError::Ignore => {} + OnError::Fail => { + warn!( + "{} plugin '{}' on_error=fail (timeout) in non-blocking phase — not halting", + phase_label, plugin_name, + ); + errors.push((&timeout_err).into()); + } + OnError::Ignore => { + errors.push((&timeout_err).into()); + } OnError::Disable => { - warn!("{} plugin '{}' disabled after error", phase_label, plugin_name); + warn!( + "{} plugin '{}' disabled after timeout", + phase_label, plugin_name + ); + errors.push((&timeout_err).into()); entry.plugin_ref.disable(); } } } } - // Store context back into the table (preserves local_state - // for the next hook invocation via the returned context_table). - // Note: global_state merging from plugin writes is deferred — - // handlers currently receive &PluginContext (shared ref) so - // they can't mutate global_state directly. When we add write-back - // (via PluginResult or interior mutability), merge here. - ctx_table.insert(plugin_id, ctx); + // Commit this plugin's context back to the table — replaces the + // canonical global_state with its (possibly modified) copy and + // stores the local_state for the next hook invocation. The + // global_state move is free; only the local_state insert allocates. + ctx_table.store_context(plugin_id, ctx); } None // no denial @@ -528,22 +611,15 @@ impl Executor { extensions: &Extensions, ctx_table: &PluginContextTable, phase_label: &str, + errors: &mut Vec, ) { - // Read-only phases get a snapshot of global state but don't merge back. - let global_state: HashMap = ctx_table - .values() - .last() - .map(|c| c.global_state.clone()) - .unwrap_or_default(); - for entry in entries { let plugin_name = entry.plugin_ref.name().to_string(); let plugin_id = entry.plugin_ref.id(); - let mut ctx = ctx_table - .get(plugin_id) - .cloned() - .map(|mut c| { c.global_state = global_state.clone(); c }) - .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); + let on_error = entry.plugin_ref.trusted_config().on_error; + // Read-only phase — snapshot the plugin's local_state and the + // canonical global_state, no merge-back. + let mut ctx = ctx_table.snapshot_context(plugin_id); // Filter extensions per plugin — read-only, no write tokens. let capabilities: std::collections::HashSet = entry .plugin_ref @@ -555,16 +631,52 @@ impl Executor { let filtered = filter_extensions(extensions, &capabilities); let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - let result = timeout(timeout_dur, entry.handler.invoke(payload, &filtered, &mut ctx)) - .await; + let result = timeout( + timeout_dur, + entry.handler.invoke(payload, &filtered, &mut ctx), + ) + .await; + // Audit / fire-and-forget cannot block, so OnError::Fail can't + // halt the pipeline — but OnError::Disable must still take a + // repeatedly-failing plugin out of rotation. The previous code + // ignored on_error entirely, so Disable plugins kept failing + // forever no matter how many invocations errored. All non-halt + // failures also push a record into PipelineResult.errors. match result { Ok(Ok(_)) => {} // read-only — discard result and ext_clone Ok(Err(e)) => { - warn!("{} plugin '{}' error (ignored): {}", phase_label, plugin_name, e); + warn!( + "{} plugin '{}' error (ignored): {}", + phase_label, plugin_name, e + ); + errors.push((&e).into()); + if matches!(on_error, OnError::Disable) { + warn!( + "{} plugin '{}' disabled after error", + phase_label, plugin_name + ); + entry.plugin_ref.disable(); + } } Err(_) => { - warn!("{} plugin '{}' timed out (ignored)", phase_label, plugin_name); + warn!( + "{} plugin '{}' timed out (ignored)", + phase_label, plugin_name + ); + let timeout_err = crate::error::PluginError::Timeout { + plugin_name: plugin_name.clone(), + timeout_ms: timeout_dur.as_millis() as u64, + proto_error_code: None, + }; + errors.push((&timeout_err).into()); + if matches!(on_error, OnError::Disable) { + warn!( + "{} plugin '{}' disabled after timeout", + phase_label, plugin_name + ); + entry.plugin_ref.disable(); + } } } } @@ -576,12 +688,20 @@ impl Executor { /// Run the concurrent phase — plugins execute truly in parallel. /// Returns the first violation if any plugin denies. + /// + /// Uses a `JoinSet` rather than `Vec + join_all` so we can: + /// - react to results as they complete (`join_next_with_id`) rather than + /// waiting for the slowest task before noticing a deny; + /// - cancel remaining tasks when a halt condition is hit (`abort_all`), + /// making `short_circuit_on_deny` actually short-circuit and bounding + /// the side-effects timed-out / errored handlers can produce. async fn run_concurrent_phase( &self, entries: &[HookEntry], payload: &dyn PluginPayload, extensions: &Extensions, ctx_table: &PluginContextTable, + errors: &mut Vec, ) -> Option { if entries.is_empty() { return None; @@ -589,31 +709,27 @@ impl Executor { // Clone the payload once so each spawned task can borrow from // an owned, 'static copy. Each task gets its own Arc'd clone. - let shared_payload: Arc> = - Arc::new(payload.clone_boxed()); + let shared_payload: Arc> = Arc::new(payload.clone_boxed()); let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - // Snapshot global state for all concurrent plugins - let global_state: HashMap = ctx_table - .values() - .last() - .map(|c| c.global_state.clone()) - .unwrap_or_default(); - - // Spawn all handlers concurrently — each task returns just - // the invoke result. We zip outcomes back with entries to - // access PluginRef for disable() without cloning it into the spawn. - let mut handles = Vec::with_capacity(entries.len()); - - for entry in entries { + // Spawn into a JoinSet keyed by tokio task::Id so we can map a + // completed task (or a panicked one — JoinError carries the id) + // back to its entry without positional zip. + type ConcurrentTaskOutput = Result< + Result, Box>, + tokio::time::error::Elapsed, + >; + let mut set: tokio::task::JoinSet = tokio::task::JoinSet::new(); + let mut id_to_index: std::collections::HashMap = + std::collections::HashMap::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { let handler = Arc::clone(&entry.handler); let payload_clone = Arc::clone(&shared_payload); - let plugin_id = entry.plugin_ref.id().to_string(); - let mut ctx = ctx_table - .get(&plugin_id) - .cloned() - .map(|mut c| { c.global_state = global_state.clone(); c }) - .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); + let plugin_id = entry.plugin_ref.id(); + // Snapshot the plugin's local_state and the canonical global_state. + // Concurrent plugins do not merge back — each task owns its copy. + let mut ctx = ctx_table.snapshot_context(plugin_id); let dur = timeout_dur; // Filter per plugin — each may have different capabilities. @@ -627,25 +743,71 @@ impl Executor { .collect(); let filtered = Arc::new(filter_extensions(extensions, &capabilities)); - let handle = tokio::spawn(async move { + let abort_handle = set.spawn(async move { timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await }); - - handles.push(handle); + id_to_index.insert(abort_handle.id(), idx); } - // Collect results — zip with entries for PluginRef access - let outcomes = futures::future::join_all(handles).await; - let mut denials = Vec::new(); + let mut denials: Vec = Vec::new(); - for (entry, outcome) in entries.iter().zip(outcomes) { + while let Some(joined) = set.join_next_with_id().await { + // Pull the task::Id and outcome out of the success/error envelope + // so we can look up the entry by id even when the task panicked. + let (task_id, outcome) = match joined { + Ok((id, result)) => (id, Ok(result)), + Err(join_err) => { + let id = join_err.id(); + (id, Err(join_err)) + } + }; + let idx = match id_to_index.get(&task_id) { + Some(i) => *i, + None => { + // Should be impossible — we registered every spawn. + error!("CONCURRENT: untracked task id {:?}", task_id); + continue; + } + }; + let entry = &entries[idx]; let plugin_name = entry.plugin_ref.name(); let on_error = entry.plugin_ref.trusted_config().on_error; let result = match outcome { Ok(r) => r, Err(e) => { - error!("CONCURRENT task panicked: {}", e); + // Spawned task panicked. Apply the plugin's on_error + // policy just like a returned error or timeout. On + // Fail, abort the remaining tasks before halting. + error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, e); + let panic_err = crate::error::PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("task panicked: {}", e), + source: None, + code: Some("panic".into()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }; + match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_panic", + format!("Plugin '{}' task panicked: {}", plugin_name, e), + ); + v.plugin_name = Some(plugin_name.to_string()); + set.abort_all(); + return Some(v); + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name); + errors.push((&panic_err).into()); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name); + errors.push((&panic_err).into()); + entry.plugin_ref.disable(); + } + } continue; } }; @@ -662,6 +824,9 @@ impl Executor { }); violation.plugin_name = Some(plugin_name.to_string()); if self.config.short_circuit_on_deny { + // Real short-circuit: cancel the rest before + // they keep running and writing side-effects. + set.abort_all(); return Some(violation); } denials.push(violation); @@ -675,37 +840,53 @@ impl Executor { format!("Plugin '{}' failed: {}", plugin_name, e), ); v.plugin_name = Some(plugin_name.to_string()); + set.abort_all(); return Some(v); } OnError::Ignore => { warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); + errors.push((&e).into()); } OnError::Disable => { warn!("CONCURRENT plugin '{}' disabled after error", plugin_name); + errors.push((&e).into()); entry.plugin_ref.disable(); } }, - Err(_) => match on_error { - OnError::Fail => { - let mut v = crate::error::PluginViolation::new( - "plugin_timeout", - format!("Plugin '{}' timed out", plugin_name), - ); - v.plugin_name = Some(plugin_name.to_string()); - return Some(v); - } - OnError::Ignore => { - warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); - } - OnError::Disable => { - warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name); - entry.plugin_ref.disable(); + Err(_) => { + let timeout_err = crate::error::PluginError::Timeout { + plugin_name: plugin_name.to_string(), + timeout_ms: timeout_dur.as_millis() as u64, + proto_error_code: None, + }; + match on_error { + OnError::Fail => { + let mut v = crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + ); + v.plugin_name = Some(plugin_name.to_string()); + set.abort_all(); + return Some(v); + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); + errors.push((&timeout_err).into()); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name); + errors.push((&timeout_err).into()); + entry.plugin_ref.disable(); + } } - }, + } } } - // Return first denial if any were collected (non-short-circuit mode) + // Return first denial if any were collected (non-short-circuit mode). + // Dropping `set` here also aborts any not-yet-completed tasks; with + // join_next_with_id() above we drained completions, so this is just + // belt-and-braces in case the loop exited unexpectedly. denials.into_iter().next() } @@ -728,17 +909,13 @@ impl Executor { payload: &dyn PluginPayload, extensions: &Extensions, ctx_table: &PluginContextTable, + task_tracker: &tokio_util::task::TaskTracker, ) -> Vec<(String, tokio::task::JoinHandle<()>)> { if entries.is_empty() { return Vec::new(); } let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - let global_state: HashMap = ctx_table - .values() - .last() - .map(|c| c.global_state.clone()) - .unwrap_or_default(); let mut handles = Vec::with_capacity(entries.len()); @@ -746,7 +923,9 @@ impl Executor { let plugin_name = entry.plugin_ref.name().to_string(); let handler = Arc::clone(&entry.handler); let owned_payload = payload.clone_boxed(); - let mut ctx = PluginContext::with_global_state(global_state.clone()); + // Snapshot per plugin so fire-and-forget tasks see their stored + // local_state from prior hooks, not just an empty context. + let mut ctx = ctx_table.snapshot_context(entry.plugin_ref.id()); let dur = timeout_dur; let name_for_log = plugin_name.clone(); @@ -760,20 +939,28 @@ impl Executor { .collect(); let filtered = Arc::new(filter_extensions(extensions, &capabilities)); - let handle = tokio::spawn(async move { - let result = timeout( - dur, - handler.invoke(&*owned_payload, &filtered, &mut ctx), - ) - .await; + // Spawn through TaskTracker so `PluginManager::shutdown()` + // can drain in-flight fire-and-forget tasks before tearing + // down. The returned JoinHandle is the same shape as + // tokio::spawn's, so callers using BackgroundTasks still + // wait_for_background_tasks() over their own handles. + let handle = task_tracker.spawn(async move { + let result = + timeout(dur, handler.invoke(&*owned_payload, &filtered, &mut ctx)).await; match result { Ok(Ok(_)) => {} // discard Ok(Err(e)) => { - warn!("FIRE_AND_FORGET plugin '{}' error (ignored): {}", name_for_log, e); + warn!( + "FIRE_AND_FORGET plugin '{}' error (ignored): {}", + name_for_log, e + ); } Err(_) => { - warn!("FIRE_AND_FORGET plugin '{}' timed out (ignored)", name_for_log); + warn!( + "FIRE_AND_FORGET plugin '{}' timed out (ignored)", + name_for_log + ); } } }); @@ -852,6 +1039,7 @@ mod tests { use crate::hooks::PluginResult; #[derive(Debug, Clone)] + #[allow(dead_code)] // test fixture — typed shape is the point, not field reads struct TestPayload { value: String, } @@ -869,9 +1057,8 @@ mod tests { #[test] fn test_erase_result_deny() { - let result: PluginResult = PluginResult::deny( - crate::error::PluginViolation::new("test", "denied"), - ); + let result: PluginResult = + PluginResult::deny(crate::error::PluginViolation::new("test", "denied")); let erased = erase_result(result); let fields = extract_erased(erased).unwrap(); assert!(!fields.continue_processing); @@ -903,7 +1090,13 @@ mod tests { let fields = extract_erased(erased).unwrap(); assert!(fields.continue_processing); assert!(fields.modified_extensions.is_some()); - let sec = fields.modified_extensions.as_ref().unwrap().security.as_ref().unwrap(); + let sec = fields + .modified_extensions + .as_ref() + .unwrap() + .security + .as_ref() + .unwrap(); assert!(sec.has_label("PII")); } @@ -912,11 +1105,8 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let result = PipelineResult::allowed_with( - payload, - Extensions::default(), - PluginContextTable::new(), - ); + let result = + PipelineResult::allowed_with(payload, Extensions::default(), PluginContextTable::new()); assert!(result.continue_processing); assert!(result.modified_payload.is_some()); assert!(result.violation.is_none()); @@ -925,11 +1115,8 @@ mod tests { #[test] fn test_pipeline_result_denied() { let violation = crate::error::PluginViolation::new("test", "denied"); - let result = PipelineResult::denied( - violation, - Extensions::default(), - PluginContextTable::new(), - ); + let result = + PipelineResult::denied(violation, Extensions::default(), PluginContextTable::new()); assert!(!result.continue_processing); assert!(result.modified_payload.is_none()); assert!(result.violation.is_some()); @@ -938,11 +1125,12 @@ mod tests { #[tokio::test] async fn test_executor_empty_entries() { let executor = Executor::default(); + let tracker = tokio_util::task::TaskTracker::new(); let payload: Box = Box::new(TestPayload { value: "test".into(), }); let (result, _) = executor - .execute(&[], payload, Extensions::default(), None) + .execute(&[], payload, Extensions::default(), None, &tracker) .await; assert!(result.continue_processing); assert!(result.modified_payload.is_some()); diff --git a/crates/cpex-core/src/extensions/container.rs b/crates/cpex-core/src/extensions/container.rs index 68f0f3a5..6409bf43 100644 --- a/crates/cpex-core/src/extensions/container.rs +++ b/crates/cpex-core/src/extensions/container.rs @@ -185,12 +185,19 @@ impl Extensions { } /// Validate that immutable slots were not tampered with. + /// + /// A slot that is `None` in modified (because capability filtering + /// hid it from the plugin) is always valid — the plugin never saw + /// it. Only flag as tampering when both are `Some` with different + /// Arc pointers, or when the original is `None` but modified is + /// `Some` (the plugin fabricated a slot it shouldn't have). pub fn validate_immutable(&self, modified: &OwnedExtensions) -> bool { fn ptr_eq_opt(a: &Option>, b: &Option>) -> bool { match (a, b) { (Some(a), Some(b)) => Arc::ptr_eq(a, b), (None, None) => true, - _ => false, + (_, None) => true, // plugin never saw it — not tampering + (None, Some(_)) => false, // plugin fabricated a slot } } @@ -289,8 +296,14 @@ mod tests { let cow = ext.cow_copy(); // Immutable slots share the same Arc — zero copy - assert!(Arc::ptr_eq(ext.request.as_ref().unwrap(), cow.request.as_ref().unwrap())); - assert!(Arc::ptr_eq(ext.meta.as_ref().unwrap(), cow.meta.as_ref().unwrap())); + assert!(Arc::ptr_eq( + ext.request.as_ref().unwrap(), + cow.request.as_ref().unwrap() + )); + assert!(Arc::ptr_eq( + ext.meta.as_ref().unwrap(), + cow.meta.as_ref().unwrap() + )); } #[test] @@ -337,7 +350,11 @@ mod tests { // Can read without token assert_eq!( - cow.http.as_ref().unwrap().read().get_header("Authorization"), + cow.http + .as_ref() + .unwrap() + .read() + .get_header("Authorization"), Some("Bearer token") ); @@ -426,8 +443,8 @@ mod tests { #[test] fn test_cow_copy_modify_multiple_fields() { - use crate::extensions::DelegationExtension; use crate::extensions::delegation::DelegationHop; + use crate::extensions::DelegationExtension; // Build extensions with security, http, delegation, custom let mut security = SecurityExtension::default(); @@ -440,7 +457,9 @@ mod tests { security: Some(Arc::new(security)), http: Some(Arc::new(http)), delegation: Some(Arc::new(DelegationExtension::default())), - custom: Some(Arc::new([("existing".to_string(), serde_json::json!("value"))].into())), + custom: Some(Arc::new( + [("existing".to_string(), serde_json::json!("value"))].into(), + )), meta: Some(Arc::new(MetaExtension { entity_type: Some("tool".into()), ..Default::default() @@ -462,8 +481,16 @@ mod tests { // 2. Inject HTTP headers (guarded) let token = cow.http_write_token.as_ref().unwrap(); - cow.http.as_mut().unwrap().write(token).set_header("X-Checked", "true"); - cow.http.as_mut().unwrap().write(token).set_header("X-Policy", "v2"); + cow.http + .as_mut() + .unwrap() + .write(token) + .set_header("X-Checked", "true"); + cow.http + .as_mut() + .unwrap() + .write(token) + .set_header("X-Policy", "v2"); // 3. Append delegation hop (monotonic) cow.delegation.as_mut().unwrap().append_hop(DelegationHop { @@ -473,27 +500,36 @@ mod tests { }); // 4. Add custom data (mutable, no token needed) - cow.custom.as_mut().unwrap().insert( - "audit.timestamp".into(), - serde_json::json!("2026-04-29"), - ); + cow.custom + .as_mut() + .unwrap() + .insert("audit.timestamp".into(), serde_json::json!("2026-04-29")); // Verify COW copy has all modifications let sec = cow.security.as_ref().unwrap(); - assert!(sec.has_label("PII")); // original - assert!(sec.has_label("CHECKED")); // added + assert!(sec.has_label("PII")); // original + assert!(sec.has_label("CHECKED")); // added assert!(sec.has_label("COMPLIANT")); // added let http = cow.http.as_ref().unwrap().read(); assert_eq!(http.get_header("Authorization"), Some("Bearer token")); // original - assert_eq!(http.get_header("X-Checked"), Some("true")); // added - assert_eq!(http.get_header("X-Policy"), Some("v2")); // added + assert_eq!(http.get_header("X-Checked"), Some("true")); // added + assert_eq!(http.get_header("X-Policy"), Some("v2")); // added assert_eq!(cow.delegation.as_ref().unwrap().chain.len(), 1); - assert_eq!(cow.delegation.as_ref().unwrap().chain[0].subject_id, "service-a"); + assert_eq!( + cow.delegation.as_ref().unwrap().chain[0].subject_id, + "service-a" + ); - assert_eq!(cow.custom.as_ref().unwrap().get("existing").unwrap(), "value"); - assert_eq!(cow.custom.as_ref().unwrap().get("audit.timestamp").unwrap(), "2026-04-29"); + assert_eq!( + cow.custom.as_ref().unwrap().get("existing").unwrap(), + "value" + ); + assert_eq!( + cow.custom.as_ref().unwrap().get("audit.timestamp").unwrap(), + "2026-04-29" + ); // Verify original is unchanged assert!(!ext.security.as_ref().unwrap().has_label("CHECKED")); @@ -505,26 +541,169 @@ mod tests { assert!(ext.validate_immutable(&cow)); } + #[test] + fn test_validate_immutable_passes_when_slot_filtered_out() { + // Bug fix regression: when capability filtering hides a slot + // from the plugin (e.g., agent=None in owned because plugin + // lacks read_agent), validate_immutable must NOT treat that + // as tampering. + let ext = make_extensions(); + let mut cow = ext.cow_copy(); + + // Simulate capability filtering hiding the agent slot + cow.agent = None; + + // Validation should pass — plugin never saw the slot + assert!(ext.validate_immutable(&cow)); + } + + #[test] + fn test_validate_immutable_fails_when_slot_fabricated() { + // If the original has no agent but the plugin returns one, + // that's fabrication — should fail. + let ext = Extensions::default(); // no agent + let mut cow = ext.cow_copy(); + + cow.agent = Some(Arc::new(AgentExtension { + agent_id: Some("fabricated".into()), + ..Default::default() + })); + + assert!(!ext.validate_immutable(&cow)); + } + + #[test] + fn test_validate_immutable_passes_multiple_slots_filtered() { + // Multiple immutable slots filtered out — all should pass + let ext = make_extensions(); + let mut cow = ext.cow_copy(); + + cow.agent = None; + cow.mcp = None; + cow.completion = None; + cow.framework = None; + + assert!(ext.validate_immutable(&cow)); + } + + #[test] + fn test_merge_owned_preserves_http_response_headers() { + // Bug fix regression: merge_owned must preserve response + // headers written by a plugin through Guarded write access. + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer tok"); + + let mut ext = Extensions { + http: Some(Arc::new(http)), + ..Default::default() + }; + ext.http_write_token = Some(WriteToken::new()); + + let mut cow = ext.cow_copy(); + + // Plugin writes response headers through the guard + let token = cow.http_write_token.as_ref().unwrap(); + let h = cow.http.as_mut().unwrap().write(token); + h.set_response_header("X-Tool-Name", "get_compensation"); + h.set_response_header("X-Status", "success"); + + // Merge back + ext.merge_owned(cow); + + // Response headers must be present after merge + let merged_http = ext.http.as_ref().unwrap(); + assert_eq!( + merged_http.get_response_header("X-Tool-Name"), + Some("get_compensation") + ); + assert_eq!(merged_http.get_response_header("X-Status"), Some("success")); + // Original request headers preserved + assert_eq!( + merged_http.get_request_header("Authorization"), + Some("Bearer tok") + ); + } + + #[test] + fn test_merge_owned_with_filtered_security() { + // A plugin without read_labels gets empty labels in its + // filtered view. After cow_copy + merge_owned, the pipeline's + // security labels must be preserved (not overwritten with empty). + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR"); + + let ext = Extensions { + security: Some(Arc::new(security)), + ..Default::default() + }; + + // Simulate: plugin has no read_labels, so filtered security + // has empty labels. cow_copy of filtered would have empty labels. + let mut cow = ext.cow_copy(); + + // Plugin's owned security has the labels (from cow_copy of full ext) + // But in the real flow, it would be from the filtered ext. + // Simulate filtered: clear labels + cow.security.as_mut().unwrap().labels = crate::extensions::MonotonicSet::new(); + + // merge_owned replaces pipeline security with owned + let mut ext_mut = ext.clone(); + ext_mut.merge_owned(cow); + + // After merge, the security comes from the owned (which had empty labels) + // This is expected — the executor's monotonic check should prevent + // this case. merge_owned itself is just a field replacement. + let merged_sec = ext_mut.security.as_ref().unwrap(); + assert!(!merged_sec.has_label("PII")); // replaced by owned + } + + #[test] + fn test_merge_owned_none_http_preserves_pipeline() { + // If owned.http is None (plugin had no read_headers capability), + // merge_owned replaces with None. The executor should only call + // merge_owned when the plugin actually modified something. + let mut http = HttpExtension::default(); + http.set_request_header("X-Original", "value"); + + let mut ext = Extensions { + http: Some(Arc::new(http)), + ..Default::default() + }; + + let mut cow = ext.cow_copy(); + cow.http = None; // simulate filtered-out HTTP + + ext.merge_owned(cow); + + // HTTP is now None — this is the raw merge behavior. + // The executor guards against this by only calling merge_owned + // when the plugin returned modify_extensions. + assert!(ext.http.is_none()); + } + #[test] fn test_read_only_plugin_zero_cost() { // Plugin that only reads — no cow_copy, no clone let ext = make_extensions(); // Read security labels - let has_pii = ext.security.as_ref() + let has_pii = ext + .security + .as_ref() .map(|s| s.has_label("PII")) .unwrap_or(false); assert!(has_pii); // Read HTTP headers - let auth = ext.http.as_ref() - .map(|h| h.get_header("Authorization")) - .flatten(); + let auth = ext + .http + .as_ref() + .and_then(|h| h.get_header("Authorization")); assert_eq!(auth, Some("Bearer token")); // Read meta - let entity = ext.meta.as_ref() - .and_then(|m| m.entity_type.as_deref()); + let entity = ext.meta.as_ref().and_then(|m| m.entity_type.as_deref()); assert_eq!(entity, Some("tool")); // No cow_copy called — zero allocations for read-only access diff --git a/crates/cpex-core/src/extensions/delegation.rs b/crates/cpex-core/src/extensions/delegation.rs index 2921cdce..e5f5ef50 100644 --- a/crates/cpex-core/src/extensions/delegation.rs +++ b/crates/cpex-core/src/extensions/delegation.rs @@ -113,8 +113,10 @@ mod tests { #[test] fn test_append_multiple_hops() { - let mut del = DelegationExtension::default(); - del.origin_subject_id = Some("alice".into()); + let mut del = DelegationExtension { + origin_subject_id: Some("alice".into()), + ..Default::default() + }; del.append_hop(DelegationHop { subject_id: "alice".into(), @@ -139,9 +141,11 @@ mod tests { #[test] fn test_delegation_serde_roundtrip() { - let mut del = DelegationExtension::default(); - del.origin_subject_id = Some("alice".into()); - del.actor_subject_id = Some("service-b".into()); + let mut del = DelegationExtension { + origin_subject_id: Some("alice".into()), + actor_subject_id: Some("service-b".into()), + ..Default::default() + }; del.append_hop(DelegationHop { subject_id: "alice".into(), subject_type: Some("user".into()), diff --git a/crates/cpex-core/src/extensions/filter.rs b/crates/cpex-core/src/extensions/filter.rs index 18bca78b..1841164a 100644 --- a/crates/cpex-core/src/extensions/filter.rs +++ b/crates/cpex-core/src/extensions/filter.rs @@ -238,21 +238,20 @@ fn cap_str(cap: Capability) -> String { /// For the security extension, filtering is granular: unrestricted /// sub-fields (objects, data, classification) are always included, /// while labels and subject sub-fields are gated by capabilities. -pub fn filter_extensions( - extensions: &Extensions, - capabilities: &HashSet, -) -> Extensions { - let mut filtered = Extensions::default(); - - // Unrestricted immutable — always visible - filtered.request = extensions.request.clone(); - filtered.provenance = extensions.provenance.clone(); - filtered.completion = extensions.completion.clone(); - filtered.llm = extensions.llm.clone(); - filtered.framework = extensions.framework.clone(); - filtered.mcp = extensions.mcp.clone(); - filtered.meta = extensions.meta.clone(); - filtered.custom = extensions.custom.clone(); +pub fn filter_extensions(extensions: &Extensions, capabilities: &HashSet) -> Extensions { + // Build the unrestricted-immutable fields up front; capability-gated + // slots stay default and are filled in below. + let mut filtered = Extensions { + request: extensions.request.clone(), + provenance: extensions.provenance.clone(), + completion: extensions.completion.clone(), + llm: extensions.llm.clone(), + framework: extensions.framework.clone(), + mcp: extensions.mcp.clone(), + meta: extensions.meta.clone(), + custom: extensions.custom.clone(), + ..Default::default() + }; // Capability-gated: delegation if extensions.delegation.is_some() { @@ -362,8 +361,8 @@ fn build_filtered_subject( #[cfg(test)] mod tests { use super::*; - use crate::extensions::SecurityExtension; use crate::extensions::meta::MetaExtension; + use crate::extensions::SecurityExtension; fn make_full_extensions() -> Extensions { let mut security = SecurityExtension::default(); @@ -401,7 +400,9 @@ mod tests { entity_name: Some("get_compensation".into()), ..Default::default() })), - custom: Some(Arc::new([("key".to_string(), serde_json::json!("value"))].into())), + custom: Some(Arc::new( + [("key".to_string(), serde_json::json!("value"))].into(), + )), ..Default::default() } } @@ -451,10 +452,7 @@ mod tests { let filtered = filter_extensions(&ext, &caps); assert!(filtered.agent.is_some()); - assert_eq!( - filtered.agent.unwrap().agent_id, - Some("agent-1".into()) - ); + assert_eq!(filtered.agent.unwrap().agent_id, Some("agent-1".into())); assert!(filtered.http.is_none()); } diff --git a/crates/cpex-core/src/extensions/guarded.rs b/crates/cpex-core/src/extensions/guarded.rs index f317e95f..fb369a16 100644 --- a/crates/cpex-core/src/extensions/guarded.rs +++ b/crates/cpex-core/src/extensions/guarded.rs @@ -135,7 +135,10 @@ mod tests { assert!(guarded.read().map.is_empty()); // Write — token required - guarded.write(&token).map.insert("X-Auth".into(), "Bearer tok".into()); + guarded + .write(&token) + .map + .insert("X-Auth".into(), "Bearer tok".into()); assert_eq!(guarded.read().map.get("X-Auth").unwrap(), "Bearer tok"); } } diff --git a/crates/cpex-core/src/extensions/http.rs b/crates/cpex-core/src/extensions/http.rs index bfd52903..3fa1157a 100644 --- a/crates/cpex-core/src/extensions/http.rs +++ b/crates/cpex-core/src/extensions/http.rs @@ -49,7 +49,11 @@ impl HttpExtension { } /// Add request header only if it doesn't exist. Returns true if added. - pub fn add_request_header(&mut self, name: impl Into, value: impl Into) -> bool { + pub fn add_request_header( + &mut self, + name: impl Into, + value: impl Into, + ) -> bool { let name = name.into(); if self.has_request_header(&name) { return false; @@ -110,10 +114,7 @@ fn get_header_ci<'a>(headers: &'a HashMap, name: &str) -> Option fn remove_header_ci(headers: &mut HashMap, name: &str) -> Option { let lower = name.to_lowercase(); - let key = headers - .keys() - .find(|k| k.to_lowercase() == lower) - .cloned(); + let key = headers.keys().find(|k| k.to_lowercase() == lower).cloned(); key.and_then(|k| headers.remove(&k)) } @@ -125,7 +126,10 @@ mod tests { fn test_request_header_set_and_get() { let mut http = HttpExtension::default(); http.set_request_header("Content-Type", "application/json"); - assert_eq!(http.get_request_header("Content-Type"), Some("application/json")); + assert_eq!( + http.get_request_header("Content-Type"), + Some("application/json") + ); } #[test] @@ -194,7 +198,13 @@ mod tests { let json = serde_json::to_string(&http).unwrap(); let deserialized: HttpExtension = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.get_request_header("Authorization"), Some("Bearer tok")); - assert_eq!(deserialized.get_response_header("Content-Type"), Some("application/json")); + assert_eq!( + deserialized.get_request_header("Authorization"), + Some("Bearer tok") + ); + assert_eq!( + deserialized.get_response_header("Content-Type"), + Some("application/json") + ); } } diff --git a/crates/cpex-core/src/extensions/mod.rs b/crates/cpex-core/src/extensions/mod.rs index 43235833..d51aec62 100644 --- a/crates/cpex-core/src/extensions/mod.rs +++ b/crates/cpex-core/src/extensions/mod.rs @@ -35,6 +35,7 @@ pub use container::{Extensions, OwnedExtensions}; pub use agent::{AgentExtension, ConversationContext}; pub use completion::{CompletionExtension, StopReason, TokenUsage}; pub use delegation::{DelegationExtension, DelegationHop}; +pub use filter::{filter_extensions, SlotName}; pub use framework::FrameworkExtension; pub use guarded::{Guarded, WriteToken}; pub use http::HttpExtension; @@ -48,5 +49,4 @@ pub use security::{ AgentIdentity, DataPolicy, ObjectSecurityProfile, RetentionPolicy, SecurityExtension, SubjectExtension, SubjectType, }; -pub use filter::{filter_extensions, SlotName}; pub use tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; diff --git a/crates/cpex-core/src/extensions/monotonic.rs b/crates/cpex-core/src/extensions/monotonic.rs index 65c004c2..82530199 100644 --- a/crates/cpex-core/src/extensions/monotonic.rs +++ b/crates/cpex-core/src/extensions/monotonic.rs @@ -80,11 +80,7 @@ impl MonotonicSet { /// Removal requires a DeclassifierToken — privileged, audited operation. /// Only the security subsystem can construct the token. - pub fn remove_with_declassifier( - &mut self, - value: &T, - _token: &DeclassifierToken, - ) -> bool { + pub fn remove_with_declassifier(&mut self, value: &T, _token: &DeclassifierToken) -> bool { self.inner.remove(value) } } diff --git a/crates/cpex-core/src/extensions/security.rs b/crates/cpex-core/src/extensions/security.rs index 717baa72..91d54c18 100644 --- a/crates/cpex-core/src/extensions/security.rs +++ b/crates/cpex-core/src/extensions/security.rs @@ -203,8 +203,10 @@ mod tests { #[test] fn test_security_classification() { - let mut sec = SecurityExtension::default(); - sec.classification = Some("confidential".into()); + let sec = SecurityExtension { + classification: Some("confidential".into()), + ..Default::default() + }; assert_eq!(sec.classification.as_deref(), Some("confidential")); } @@ -275,8 +277,14 @@ mod tests { // Caller identity assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice")); // Agent identity (distinct from caller) - assert_eq!(sec.agent.as_ref().unwrap().client_id.as_deref(), Some("hr-agent")); - assert_eq!(sec.agent.as_ref().unwrap().trust_domain.as_deref(), Some("corp.com")); + assert_eq!( + sec.agent.as_ref().unwrap().client_id.as_deref(), + Some("hr-agent") + ); + assert_eq!( + sec.agent.as_ref().unwrap().trust_domain.as_deref(), + Some("corp.com") + ); // Auth method assert_eq!(sec.auth_method.as_deref(), Some("jwt")); // Labels @@ -332,6 +340,9 @@ mod tests { }; assert_eq!(policy.apply_labels[0], "PII"); assert!(policy.retention.is_some()); - assert_eq!(policy.retention.as_ref().unwrap().max_age_seconds, Some(86400)); + assert_eq!( + policy.retention.as_ref().unwrap().max_age_seconds, + Some(86400) + ); } } diff --git a/crates/cpex-core/src/factory.rs b/crates/cpex-core/src/factory.rs index e77f80ca..95297d67 100644 --- a/crates/cpex-core/src/factory.rs +++ b/crates/cpex-core/src/factory.rs @@ -45,7 +45,7 @@ use crate::registry::AnyHookHandler; /// /// impl PluginFactory for RateLimiterFactory { /// fn create(&self, config: &PluginConfig) -/// -> Result +/// -> Result> /// { /// let plugin = Arc::new(RateLimiter::from_config(config)?); /// let handler = Arc::new(TypedHandlerAdapter::::new( @@ -62,7 +62,7 @@ pub trait PluginFactory: Send + Sync { /// Create a plugin instance and its handler from config. /// /// The `config` is the plugin's entry from the YAML file. - fn create(&self, config: &PluginConfig) -> Result; + fn create(&self, config: &PluginConfig) -> Result>; } /// A created plugin instance — the plugin and its type-erased handlers. @@ -110,11 +110,7 @@ impl PluginFactoryRegistry { } /// Register a factory for a given `kind` name. - pub fn register( - &mut self, - kind: impl Into, - factory: Box, - ) { + pub fn register(&mut self, kind: impl Into, factory: Box) { self.factories.insert(kind.into(), factory); } diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs index d60b0376..7acc7b12 100644 --- a/crates/cpex-core/src/hooks/adapter.rs +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -84,17 +84,18 @@ where payload: &dyn PluginPayload, extensions: &Extensions, ctx: &mut PluginContext, - ) -> Result, PluginError> { - let typed_ref: &H::Payload = payload - .as_any() - .downcast_ref::() - .ok_or_else(|| PluginError::Config { - message: format!( - "payload type mismatch for hook '{}': expected {}", - H::NAME, - std::any::type_name::() - ), - })?; + ) -> Result, Box> { + let typed_ref: &H::Payload = + payload + .as_any() + .downcast_ref::() + .ok_or_else(|| PluginError::Config { + message: format!( + "payload type mismatch for hook '{}': expected {}", + H::NAME, + std::any::type_name::() + ), + })?; let result = self.plugin.handle(typed_ref, extensions, ctx); let plugin_result: PluginResult = result.into(); diff --git a/crates/cpex-core/src/hooks/payload.rs b/crates/cpex-core/src/hooks/payload.rs index 2a9b2949..d284bf4c 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -27,9 +27,7 @@ use std::fmt; // These are the typed containers for all extension data. They live in // extensions/container.rs but are re-exported here for backward // compatibility with existing code that imports from hooks::payload. -pub use crate::extensions::{ - Extensions, Guarded, MetaExtension, OwnedExtensions, WriteToken, -}; +pub use crate::extensions::{Extensions, Guarded, MetaExtension, OwnedExtensions, WriteToken}; // --------------------------------------------------------------------------- // PluginPayload Trait @@ -133,4 +131,3 @@ macro_rules! impl_plugin_payload { } }; } - diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index c95aa3e7..f2f8f80c 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -24,10 +24,10 @@ pub mod cmf; pub mod config; -pub mod extensions; pub mod context; pub mod error; pub mod executor; +pub mod extensions; pub mod factory; pub mod hooks; pub mod manager; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index e72d17c7..3ad5977a 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -26,6 +26,7 @@ use std::hash::{Hash, Hasher}; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, RwLock}; use hashbrown::HashMap; @@ -47,17 +48,28 @@ use crate::registry::{AnyHookHandler, PluginRef, PluginRegistry}; // Manager Configuration // --------------------------------------------------------------------------- +/// Default upper bound on the routing cache. Caps memory growth from +/// attacker-controlled entity names without forcing operators to tune. +pub const DEFAULT_ROUTE_CACHE_MAX_ENTRIES: usize = 10_000; + /// Configuration for the PluginManager. #[derive(Debug, Clone)] pub struct ManagerConfig { /// Executor configuration (timeout, short-circuit behavior). pub executor: ExecutorConfig, + + /// Maximum number of entries in the routing cache. When the cache + /// reaches this size, further inserts are rejected (with a one-shot + /// warn log) and resolutions fall back to the slow path. See + /// `PluginSettings::route_cache_max_entries` for the YAML surface. + pub route_cache_max_entries: usize, } impl Default for ManagerConfig { fn default() -> Self { Self { executor: ExecutorConfig::default(), + route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES, } } } @@ -132,8 +144,20 @@ impl PartialEq for RouteCacheKey { impl Eq for RouteCacheKey {} - -pub struct PluginManager { +/// Mutable runtime state held atomically swappable behind `ArcSwap`. +/// +/// Every read on the hot path (`invoke_*`) does a single atomic load to +/// get an `Arc` — no locks. Mutating operations +/// (`register_*`, `load_config`, `unregister`) clone the current snapshot, +/// mutate the clone, and atomically swap the new `Arc` in. Old readers +/// finish on the old snapshot; new readers see the new one. This is the +/// classic Read-Copy-Update / RCU pattern: lock-free reads, copy-on-write +/// writes, no reader-writer contention. +/// +/// Cloning `PluginRegistry` is cheap because every value inside (`PluginRef`, +/// `AnyHookHandler`) is `Arc`-counted — only the `HashMap` shells duplicate. +#[derive(Clone)] +struct RuntimeSnapshot { /// Plugin registry — stores PluginRefs and hook-to-handler mappings. registry: PluginRegistry, @@ -143,10 +167,25 @@ pub struct PluginManager { /// Parsed CPEX config (when loaded from file). Used for route resolution. cpex_config: Option, + /// Maximum number of entries the route cache will hold. Once reached, + /// new resolutions are computed normally but not memoized (reject-on-full). + route_cache_max_entries: usize, +} + +pub struct PluginManager { + /// Hot-path runtime state. Swapped atomically on registration / config + /// reload — readers see a consistent view via a single `load_full()`. + runtime: arc_swap::ArcSwap, + /// Factory registry — owned by the manager. Used for initial /// instantiation and for creating override instances when routes /// override a plugin's base config. - factories: PluginFactoryRegistry, + /// + /// Held in a `RwLock` rather than the `ArcSwap` snapshot because + /// `Box` is not `Clone`. Read on the slow path + /// (route cache miss + override config); write on `register_factory`. + /// The hot path never touches it. + factories: RwLock, /// Cache of resolved hook entries per (entity, hook, scope). /// Populated on first access, invalidated on config reload. @@ -156,26 +195,169 @@ pub struct PluginManager { /// Hasher builder for zero-allocation cache lookups via raw_entry. cache_hasher: hashbrown::DefaultHashBuilder, + /// Set to true after the first time the cache rejects an insert in a + /// given fill cycle, so the warn log fires once per cycle rather than + /// on every miss under DoS. Reset by `clear_routing_cache()`. + route_cache_full_warned: AtomicBool, + + /// Whether initialize() has been called. Atomic so lifecycle methods + /// can be `&self` and the manager itself can sit behind `Arc`. + initialized: AtomicBool, + + /// Tracks in-flight fire-and-forget background tasks across all + /// invocations so `shutdown()` can wait for them to drain before + /// returning. Without this, audit/telemetry tasks spawned by recent + /// invokes get cancelled when the runtime tears down. Tasks are + /// `tracker.spawn`'d in `spawn_fire_and_forget`; `shutdown()` calls + /// `close().wait().await`. + /// + /// `TaskTracker` is internally `Arc`'d, so cloning is a refcount bump. + task_tracker: tokio_util::task::TaskTracker, +} + +/// Emit warnings for YAML settings that the runtime doesn't currently +/// honor. Called once per `load_config` / `from_config` so operators +/// who set these knobs aren't silently ignored. +/// +/// `user_patterns` / `content_types` on `PluginCondition` are not warned +/// — they were wired up alongside this fix and now actually filter. +fn warn_on_inactive_settings(cfg: &CpexConfig) { + if !cfg.plugin_dirs.is_empty() { + warn!( + "config sets `plugin_dirs` (count={}) but the runtime does not \ + scan directories for plugins — plugins must be registered via \ + `register_factory()` and listed under `plugins:`. Setting ignored.", + cfg.plugin_dirs.len(), + ); + } + if cfg.plugin_settings.parallel_execution_within_band { + warn!( + "config sets `plugin_settings.parallel_execution_within_band: true` \ + but the runtime does not honor it — use `mode: concurrent` on \ + individual plugins for parallel execution. Setting ignored.", + ); + } + if cfg.plugin_settings.fail_on_plugin_error { + warn!( + "config sets `plugin_settings.fail_on_plugin_error: true` but the \ + runtime does not honor it — use per-plugin `on_error: fail` for \ + that behavior. Setting ignored.", + ); + } +} + +/// Instantiate every plugin in `plugin_configs` via the matching factory +/// and register the resulting handlers into `target_registry`. Shared by +/// `PluginManager::from_config` (fresh registry) and `load_config` (clone +/// of the existing registry) so the instantiation loop lives in one place. +/// +/// Returns on the first failure (factory missing, factory.create error, or +/// duplicate-name registration). On error, `target_registry` is in a +/// partial state — both callers discard it on failure (load_config builds +/// the new registry on a clone and only swaps on Ok; from_config bails +/// before publishing the snapshot). +fn instantiate_plugins_into( + target_registry: &mut PluginRegistry, + plugin_configs: &[crate::plugin::PluginConfig], + factories: &PluginFactoryRegistry, +) -> Result<(), Box> { + for plugin_config in plugin_configs { + let factory = factories + .get(&plugin_config.kind) + .ok_or_else(|| PluginError::Config { + message: format!( + "no factory registered for plugin kind '{}' (plugin '{}')", + plugin_config.kind, plugin_config.name + ), + })?; + + let instance = factory.create(plugin_config)?; + + target_registry + .register_multi_handler(instance.plugin, plugin_config.clone(), instance.handlers) + .map_err(|msg| Box::new(PluginError::Config { message: msg }))?; + + info!( + "Registered plugin '{}' (kind: '{}') for hooks: {:?}", + plugin_config.name, plugin_config.kind, plugin_config.hooks + ); + } + Ok(()) +} - /// Whether initialize() has been called. - initialized: bool, +/// Build a `RuntimeSnapshot` from a populated registry plus the YAML +/// settings on `cpex_config`. Pulls executor timeout / short-circuit and +/// the route-cache cap from `plugin_settings` so both registration paths +/// agree on field-by-field translation. +fn snapshot_from_config(registry: PluginRegistry, cpex_config: CpexConfig) -> RuntimeSnapshot { + let executor = Executor::new(ExecutorConfig { + timeout_seconds: cpex_config.plugin_settings.plugin_timeout, + short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, + }); + let route_cache_max_entries = cpex_config.plugin_settings.route_cache_max_entries; + RuntimeSnapshot { + registry, + executor, + cpex_config: Some(cpex_config), + route_cache_max_entries, + } } impl PluginManager { /// Create a new PluginManager with the given configuration. pub fn new(config: ManagerConfig) -> Self { let cache_hasher = hashbrown::DefaultHashBuilder::default(); - Self { + let snapshot = RuntimeSnapshot { registry: PluginRegistry::new(), executor: Executor::new(config.executor), cpex_config: None, - factories: PluginFactoryRegistry::new(), - route_cache: RwLock::new(HashMap::with_hasher(cache_hasher.clone())), + route_cache_max_entries: config.route_cache_max_entries, + }; + Self { + runtime: arc_swap::ArcSwap::from_pointee(snapshot), + factories: RwLock::new(PluginFactoryRegistry::new()), + route_cache: RwLock::new(HashMap::with_hasher(cache_hasher)), cache_hasher, - initialized: false, + route_cache_full_warned: AtomicBool::new(false), + initialized: AtomicBool::new(false), + task_tracker: tokio_util::task::TaskTracker::new(), } } + /// Load the current runtime snapshot (lock-free, single atomic op). + fn load_runtime(&self) -> Arc { + self.runtime.load_full() + } + + /// Apply a mutation to the runtime snapshot via copy-on-write. + /// Clones the current snapshot, runs the closure on the clone, and + /// atomically swaps it in. Concurrent readers continue using the old + /// snapshot; subsequent readers see the new one. + fn mutate_runtime(&self, f: F) -> R + where + F: FnOnce(&mut RuntimeSnapshot) -> R, + { + let current = self.runtime.load_full(); + let mut next = (*current).clone(); + let result = f(&mut next); + self.runtime.store(Arc::new(next)); + result + } + + /// Like `mutate_runtime` but the mutation can fail — the new snapshot + /// is only published on `Ok`. On `Err`, the original snapshot is + /// untouched, so a partially-mutated clone is silently discarded. + fn try_mutate_runtime(&self, f: F) -> Result + where + F: FnOnce(&mut RuntimeSnapshot) -> Result, + { + let current = self.runtime.load_full(); + let mut next = (*current).clone(); + let result = f(&mut next)?; + self.runtime.store(Arc::new(next)); + Ok(result) + } + // ----------------------------------------------------------------------- // Factory Registration // ----------------------------------------------------------------------- @@ -194,11 +376,14 @@ impl PluginManager { /// manager.load_config(Path::new("plugins.yaml"))?; /// ``` pub fn register_factory( - &mut self, + &self, kind: impl Into, factory: Box, ) { - self.factories.register(kind, factory); + self.factories + .write() + .unwrap_or_else(|p| p.into_inner()) + .register(kind, factory); } // ----------------------------------------------------------------------- @@ -220,7 +405,7 @@ impl PluginManager { /// manager.load_config_file(Path::new("plugins/config.yaml"))?; /// manager.initialize().await?; /// ``` - pub fn load_config_file(&mut self, path: &Path) -> Result<(), PluginError> { + pub fn load_config_file(&self, path: &Path) -> Result<(), Box> { let cpex_config = config::load_config(path)?; self.load_config(cpex_config) } @@ -230,46 +415,30 @@ impl PluginManager { /// Looks up each plugin's `kind` in the factory registry, /// instantiates the plugins, and registers them with their /// hook names from the config. - pub fn load_config(&mut self, cpex_config: CpexConfig) -> Result<(), PluginError> { - // Update executor settings from config - self.executor = Executor::new(ExecutorConfig { - timeout_seconds: cpex_config.plugin_settings.plugin_timeout, - short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, - }); + pub fn load_config(&self, cpex_config: CpexConfig) -> Result<(), Box> { + warn_on_inactive_settings(&cpex_config); - // Instantiate and register each plugin from config - for plugin_config in &cpex_config.plugins { - let factory = self.factories.get(&plugin_config.kind).ok_or_else(|| { - PluginError::Config { - message: format!( - "no factory registered for plugin kind '{}' (plugin '{}')", - plugin_config.kind, plugin_config.name - ), - } - })?; + // Build the new snapshot from the current one — copy-on-write so + // concurrent invokes keep using the existing config until we swap. + // We can't use mutate_runtime here because we need to atomically + // ALSO build a new executor + new cache cap from the same config — + // the snapshot fields are coupled. + let factories = self.factories.read().unwrap_or_else(|p| p.into_inner()); + let current = self.runtime.load_full(); + let mut new_registry = current.registry.clone(); - let instance = factory.create(plugin_config)?; + instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, &factories)?; - self.registry - .register_multi_handler( - instance.plugin, - plugin_config.clone(), - instance.handlers, - ) - .map_err(|msg| PluginError::Config { message: msg })?; + // Drop the factories read lock before taking other locks + // (route_cache write below) to avoid lock-ordering hazards. + drop(factories); - info!( - "Registered plugin '{}' (kind: '{}') for hooks: {:?}", - plugin_config.name, plugin_config.kind, plugin_config.hooks - ); - } + self.runtime + .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); - // Clear routing cache — config changed + // Clear routing cache — config changed. self.clear_routing_cache(); - // Store config for route resolution - self.cpex_config = Some(cpex_config); - Ok(()) } @@ -282,39 +451,22 @@ impl PluginManager { pub fn from_config( cpex_config: CpexConfig, factories: &PluginFactoryRegistry, - ) -> Result { - let mut manager = Self::new(ManagerConfig::default()); - - // Instantiate and register each plugin - for plugin_config in &cpex_config.plugins { - let factory = factories.get(&plugin_config.kind).ok_or_else(|| { - PluginError::Config { - message: format!( - "no factory registered for plugin kind '{}' (plugin '{}')", - plugin_config.kind, plugin_config.name - ), - } - })?; + ) -> Result> { + warn_on_inactive_settings(&cpex_config); - let instance = factory.create(plugin_config)?; + let manager = Self::new(ManagerConfig { + executor: ExecutorConfig::default(), + route_cache_max_entries: cpex_config.plugin_settings.route_cache_max_entries, + }); - manager - .registry - .register_multi_handler( - instance.plugin, - plugin_config.clone(), - instance.handlers, - ) - .map_err(|msg| PluginError::Config { message: msg })?; - } + // Instantiate into a fresh registry, then publish atomically. + let mut new_registry = PluginRegistry::new(); + instantiate_plugins_into(&mut new_registry, &cpex_config.plugins, factories)?; - // Update executor from config settings - manager.executor = Executor::new(ExecutorConfig { - timeout_seconds: cpex_config.plugin_settings.plugin_timeout, - short_circuit_on_deny: cpex_config.plugin_settings.short_circuit_on_deny, - }); + manager + .runtime + .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); - manager.cpex_config = Some(cpex_config); Ok(manager) } @@ -343,10 +495,10 @@ impl PluginManager { /// manager.register_handler::(plugin, config)?; /// ``` pub fn register_handler( - &mut self, + &self, plugin: Arc

, config: PluginConfig, - ) -> Result<(), PluginError> + ) -> Result<(), Box> where H: HookTypeDef, H::Result: Into>, @@ -354,9 +506,13 @@ impl PluginManager { { let handler: Arc = Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); - self.registry - .register::(plugin, config, handler) - .map_err(|msg| PluginError::Config { message: msg }) + self.try_mutate_runtime(|snap| { + snap.registry + .register::(plugin, config, handler) + .map_err(|msg| Box::new(PluginError::Config { message: msg })) + })?; + self.clear_routing_cache(); + Ok(()) } /// Register a plugin handler for multiple hook names. @@ -373,11 +529,11 @@ impl PluginManager { /// )?; /// ``` pub fn register_handler_for_names( - &mut self, + &self, plugin: Arc

, config: PluginConfig, names: &[&str], - ) -> Result<(), PluginError> + ) -> Result<(), Box> where H: HookTypeDef, H::Result: Into>, @@ -385,9 +541,13 @@ impl PluginManager { { let handler: Arc = Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); - self.registry - .register_for_names::(plugin, config, handler, names) - .map_err(|msg| PluginError::Config { message: msg }) + self.try_mutate_runtime(|snap| { + snap.registry + .register_for_names::(plugin, config, handler, names) + .map_err(|msg| Box::new(PluginError::Config { message: msg })) + })?; + self.clear_routing_cache(); + Ok(()) } /// Register with an explicit AnyHookHandler (advanced use). @@ -396,14 +556,18 @@ impl PluginManager { /// Python/WASM bridge hosts that implement AnyHookHandler directly. /// Most callers should use `register_handler` instead. pub fn register_raw( - &mut self, + &self, plugin: Arc, config: PluginConfig, handler: Arc, - ) -> Result<(), PluginError> { - self.registry - .register::(plugin, config, handler) - .map_err(|msg| PluginError::Config { message: msg }) + ) -> Result<(), Box> { + self.try_mutate_runtime(|snap| { + snap.registry + .register::(plugin, config, handler) + .map_err(|msg| Box::new(PluginError::Config { message: msg })) + })?; + self.clear_routing_cache(); + Ok(()) } // ----------------------------------------------------------------------- @@ -415,29 +579,33 @@ impl PluginManager { /// Calls `plugin.initialize()` on each registered plugin. Must be /// called before invoking any hooks. Idempotent — calling twice /// has no effect. - pub async fn initialize(&mut self) -> Result<(), PluginError> { - if self.initialized { + pub async fn initialize(&self) -> Result<(), Box> { + if self.initialized.load(Ordering::Acquire) { return Ok(()); } + // Snapshot once at start — subsequent registrations don't affect + // this initialize() call. They'd need their own initialize. + let snapshot = self.load_runtime(); + info!( "Initializing PluginManager with {} plugins", - self.registry.plugin_count() + snapshot.registry.plugin_count() ); let mut initialized_plugins: Vec = Vec::new(); - for name in self.registry.plugin_names() { - if let Some(plugin_ref) = self.registry.get(name) { + for name in snapshot.registry.plugin_names() { + if let Some(plugin_ref) = snapshot.registry.get(&name) { let plugin = plugin_ref.plugin().clone(); - let plugin_name = name.to_string(); + let plugin_name = name; if let Err(e) = plugin.initialize().await { error!("Failed to initialize plugin '{}': {}", plugin_name, e); // Clean up already-initialized plugins for init_name in initialized_plugins.iter().rev() { - if let Some(pr) = self.registry.get(init_name) { + if let Some(pr) = snapshot.registry.get(init_name) { if let Err(shutdown_err) = pr.plugin().shutdown().await { error!( "Error shutting down plugin '{}' during rollback: {}", @@ -447,21 +615,21 @@ impl PluginManager { } } - return Err(PluginError::Execution { + return Err(Box::new(PluginError::Execution { plugin_name, message: format!("initialization failed: {}", e), source: Some(Box::new(e)), code: None, details: std::collections::HashMap::new(), proto_error_code: None, - }); + })); } initialized_plugins.push(plugin_name); } } - self.initialized = true; + self.initialized.store(true, Ordering::Release); info!("PluginManager initialized successfully"); Ok(()) } @@ -471,15 +639,29 @@ impl PluginManager { /// Calls `plugin.shutdown()` on each registered plugin in reverse /// registration order. Errors are logged but do not halt the /// shutdown process — all plugins get a chance to clean up. - pub async fn shutdown(&mut self) { - if !self.initialized { + /// Shut the manager down. **Terminal:** after `shutdown()` returns, + /// no further `register_*` / `invoke_*` should be called. New + /// fire-and-forget tasks spawned after `close()` will not be tracked + /// (the `TaskTracker` is single-shot by design). + pub async fn shutdown(&self) { + if !self.initialized.load(Ordering::Acquire) { return; } info!("Shutting down PluginManager"); - for name in self.registry.plugin_names() { - if let Some(plugin_ref) = self.registry.get(name) { + // Drain in-flight fire-and-forget tasks BEFORE tearing down + // plugins — otherwise audit/telemetry tasks that depend on the + // plugin being alive (or the runtime being up) get cancelled + // mid-flight. `close()` prevents new tasks from being tracked + // (existing in-flight ones still complete); `wait()` returns + // when the in-flight count drops to zero. + self.task_tracker.close(); + self.task_tracker.wait().await; + + let snapshot = self.load_runtime(); + for name in snapshot.registry.plugin_names() { + if let Some(plugin_ref) = snapshot.registry.get(&name) { let plugin = plugin_ref.plugin().clone(); if let Err(e) = plugin.shutdown().await { @@ -489,7 +671,7 @@ impl PluginManager { } } - self.initialized = false; + self.initialized.store(false, Ordering::Release); info!("PluginManager shutdown complete"); } @@ -524,8 +706,12 @@ impl PluginManager { extensions: Extensions, context_table: Option, ) -> (PipelineResult, BackgroundTasks) { + // Single atomic load — own the snapshot for the rest of the call so + // a concurrent register/load_config swapping in a new snapshot doesn't + // change our view mid-pipeline. + let snapshot = self.load_runtime(); let hook_type = HookType::new(hook_name); - let all_entries = self.registry.entries_for_hook(&hook_type); + let all_entries = snapshot.registry.entries_for_hook(&hook_type); if all_entries.is_empty() { return ( @@ -538,7 +724,9 @@ impl PluginManager { ); } - let entries = self.filter_entries_by_route(all_entries, &extensions, hook_name); + let entries = self + .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name) + .await; if entries.is_empty() { return ( @@ -551,8 +739,15 @@ impl PluginManager { ); } - self.executor - .execute(&entries, payload, extensions, context_table) + snapshot + .executor + .execute( + &entries, + payload, + extensions, + context_table, + &self.task_tracker, + ) .await } @@ -592,38 +787,40 @@ impl PluginManager { extensions: Extensions, context_table: Option, ) -> (PipelineResult, BackgroundTasks) { + let snapshot = self.load_runtime(); let hook_type = HookType::new(H::NAME); - let all_entries = self.registry.entries_for_hook(&hook_type); + let all_entries = snapshot.registry.entries_for_hook(&hook_type); if all_entries.is_empty() { let boxed: Box = Box::new(payload); return ( - PipelineResult::allowed_with( - boxed, - extensions, - context_table.unwrap_or_default(), - ), + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), BackgroundTasks::empty(), ); } - let entries = self.filter_entries_by_route(all_entries, &extensions, H::NAME); + let entries = self + .filter_entries_by_route(&snapshot, all_entries, &extensions, H::NAME) + .await; if entries.is_empty() { let boxed: Box = Box::new(payload); return ( - PipelineResult::allowed_with( - boxed, - extensions, - context_table.unwrap_or_default(), - ), + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), BackgroundTasks::empty(), ); } let boxed: Box = Box::new(payload); - self.executor - .execute(&entries, boxed, extensions, context_table) + snapshot + .executor + .execute( + &entries, + boxed, + extensions, + context_table, + &self.task_tracker, + ) .await } @@ -661,38 +858,40 @@ impl PluginManager { extensions: Extensions, context_table: Option, ) -> (PipelineResult, BackgroundTasks) { + let snapshot = self.load_runtime(); let hook_type = HookType::new(hook_name); - let all_entries = self.registry.entries_for_hook(&hook_type); + let all_entries = snapshot.registry.entries_for_hook(&hook_type); if all_entries.is_empty() { let boxed: Box = Box::new(payload); return ( - PipelineResult::allowed_with( - boxed, - extensions, - context_table.unwrap_or_default(), - ), + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), BackgroundTasks::empty(), ); } - let entries = self.filter_entries_by_route(all_entries, &extensions, hook_name); + let entries = self + .filter_entries_by_route(&snapshot, all_entries, &extensions, hook_name) + .await; if entries.is_empty() { let boxed: Box = Box::new(payload); return ( - PipelineResult::allowed_with( - boxed, - extensions, - context_table.unwrap_or_default(), - ), + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), BackgroundTasks::empty(), ); } let boxed: Box = Box::new(payload); - self.executor - .execute(&entries, boxed, extensions, context_table) + snapshot + .executor + .execute( + &entries, + boxed, + extensions, + context_table, + &self.task_tracker, + ) .await } @@ -710,16 +909,27 @@ impl PluginManager { /// (refcount bump, no data copy). /// /// When routing is disabled or meta is absent, returns all entries. - fn filter_entries_by_route( + async fn filter_entries_by_route( &self, + snapshot: &RuntimeSnapshot, entries: &[crate::registry::HookEntry], extensions: &Extensions, hook_name: &str, ) -> Arc> { - // If no config or routing disabled, return all - let cpex_config = match &self.cpex_config { + // Routing disabled (or no config): fall back to per-plugin + // condition filtering. Empty conditions Vec means "fire always", + // so this is backward-compatible with configs that don't use + // conditions. Mirrors the Python implementation. + let cpex_config = match &snapshot.cpex_config { Some(c) if c.routing_enabled() => c, - _ => return Arc::new(entries.to_vec()), + _ => { + let filtered: Vec<_> = entries + .iter() + .filter(|e| e.plugin_ref.trusted_config().passes_conditions(extensions)) + .cloned() + .collect(); + return Arc::new(filtered); + } }; // Extract entity info from meta extension @@ -746,7 +956,16 @@ impl PluginManager { hasher.finish() }; { - let cache = self.route_cache.read().unwrap(); + // Recover from poisoning: a panic in another thread while holding + // this lock leaves the cache flagged poisoned. The cache's contents + // are still valid (HashMap operations are panic-safe and stale + // entries are healed by `clear_routing_cache()`), so we don't want + // a one-time panic to permanently disable dispatch. Same idiom + // applies to all four lock sites in this file. + let cache = self + .route_cache + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some((_, cached)) = cache.raw_entry().from_hash(hash, |key| { key.entity_type == entity_type && key.entity_name == entity_name @@ -771,10 +990,15 @@ impl PluginManager { // create a new instance with the merged config. let mut filtered = Vec::new(); for resolved_plugin in &resolved { - if let Some(entry) = entries.iter().find(|e| e.plugin_ref.name() == resolved_plugin.name) { + if let Some(entry) = entries + .iter() + .find(|e| e.plugin_ref.name() == resolved_plugin.name) + { if let Some(overrides) = &resolved_plugin.config_overrides { // Try to create an override instance - if let Some(override_entry) = self.create_override_instance(entry, overrides) { + if let Some(override_entry) = + self.create_override_instance(entry, overrides).await + { filtered.push(override_entry); continue; } @@ -785,16 +1009,37 @@ impl PluginManager { let cached = Arc::new(filtered); - // Store in cache — owned key allocated only on cache miss + // Store in cache — owned key allocated only on cache miss. + // Reject-on-full: when the cache is at capacity we still return + // the freshly resolved Vec but skip memoization, bounding memory + // growth from attacker-controlled entity names. let cache_key = RouteCacheKey { entity_type: entity_type.to_string(), entity_name: entity_name.to_string(), hook_name: hook_name.to_string(), scope: meta.scope.clone(), }; - { - let mut cache = self.route_cache.write().unwrap(); - cache.insert(cache_key, Arc::clone(&cached)); + // Decide under the lock; log outside it so I/O doesn't block readers. + // One warn per fill cycle — prevents log spam under DoS. + let should_warn = { + let mut cache = self + .route_cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if cache.len() >= snapshot.route_cache_max_entries { + !self.route_cache_full_warned.swap(true, Ordering::AcqRel) + } else { + cache.insert(cache_key, Arc::clone(&cached)); + false + } + }; + if should_warn { + warn!( + max_entries = snapshot.route_cache_max_entries, + "Routing cache at capacity — further routes will not be cached. \ + Increase plugin_settings.route_cache_max_entries or \ + investigate entity name growth.", + ); } cached @@ -803,9 +1048,28 @@ impl PluginManager { /// Create an override plugin instance with merged config. /// /// When a route overrides a plugin's config, we create a new - /// instance via the factory with the merged config. Returns - /// None if no factory is available for the plugin's kind. - fn create_override_instance( + /// instance via the factory with the merged config and call + /// `initialize()` on it so plugins that open DB connections / file + /// handles / network clients run their setup. + /// + /// The override gets its OWN circuit breaker (`disabled` flag) and + /// its own UUID, independent of the base. Config is part of the + /// failure surface — an override with a bad connection string / + /// wrong credentials / wrong limit value can fail for reasons that + /// have nothing to do with the base's reliability. Coupling them + /// would let a config-specific failure on one route silently + /// disable the plugin on every other route, which is the opposite + /// of the per-route blast-radius guarantee operators reach for + /// overrides to get. The fresh UUID also keys the override's + /// `local_state` in the context table, isolating per-instance + /// state from the base for the same reason. + /// + /// Returns `None` (and the caller falls back to the base entry) if: + /// - no factory is available for the plugin's kind, + /// - the factory fails to create the instance, + /// - the new instance has no handler for the target hook, + /// - or `initialize()` fails on the new instance. + async fn create_override_instance( &self, base_entry: &crate::registry::HookEntry, overrides: &serde_json::Value, @@ -813,8 +1077,6 @@ impl PluginManager { let base_config = base_entry.plugin_ref.trusted_config(); let kind = &base_config.kind; - let factory = self.factories.get(kind)?; - // Merge: start with base config, overlay with overrides let mut merged_config = base_config.clone(); if let Some(override_config) = overrides.get("config") { @@ -834,52 +1096,88 @@ impl PluginManager { } } - // Create new instance with merged config + // Create new instance with merged config — hold the factories + // read lock just long enough to construct the instance, then drop + // it before any `.await` so we never hold a sync lock across awaits. let target_hook = base_entry.handler.hook_type_name(); - match factory.create(&merged_config) { - Ok(instance) => { - // Find the handler matching the current hook - let handler = instance - .handlers - .into_iter() - .find(|(name, _)| *name == target_hook) - .map(|(_, h)| h); - - if let Some(handler) = handler { - let plugin_ref = - crate::registry::PluginRef::new(instance.plugin, merged_config); - Some(crate::registry::HookEntry { - plugin_ref, - handler, - }) - } else { - warn!( - "Override instance for '{}' has no handler for hook '{}'", - base_config.name, target_hook + let instance = { + let factories = self.factories.read().unwrap_or_else(|p| p.into_inner()); + let factory = match factories.get(kind) { + Some(f) => f, + None => return None, + }; + match factory.create(&merged_config) { + Ok(i) => i, + Err(e) => { + error!( + "Failed to create override instance for '{}': {}", + base_config.name, e ); - None + return None; // fall back to base instance } } - Err(e) => { - error!( - "Failed to create override instance for '{}': {}", - base_config.name, e + }; + + // Find the handler matching the current hook before consuming + // the instance so we don't pay for initialization on a doomed instance. + let handler = instance + .handlers + .into_iter() + .find(|(name, _)| *name == target_hook) + .map(|(_, h)| h); + let handler = match handler { + Some(h) => h, + None => { + warn!( + "Override instance for '{}' has no handler for hook '{}'", + base_config.name, target_hook ); - None // fall back to base instance + return None; } + }; + + // Initialize the new instance — without this, plugins that need to + // set up DB connections / file handles / network clients run with + // default state. + if let Err(e) = instance.plugin.initialize().await { + error!( + "Failed to initialize override instance for '{}': {} — falling back to base", + base_config.name, e + ); + return None; } + + // Independent circuit breaker + fresh UUID per (kind, name, config) + // — see the doc comment above for why we don't share with the base. + // Arc-wrapped for cheap cloning under group_by_mode. + let plugin_ref = Arc::new(crate::registry::PluginRef::new( + instance.plugin, + merged_config, + )); + Some(crate::registry::HookEntry { + plugin_ref, + handler, + }) } /// Clear the routing cache. Call when config is reloaded or - /// plugins are registered/unregistered. + /// plugins are registered/unregistered. Also resets the + /// "cache full" warn-once latch so the next fill cycle can warn again. pub fn clear_routing_cache(&self) { - let mut cache = self.route_cache.write().unwrap(); + let mut cache = self + .route_cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); cache.clear(); + self.route_cache_full_warned.store(false, Ordering::Release); } /// Number of entries in the routing cache. pub fn routing_cache_size(&self) -> usize { - self.route_cache.read().unwrap().len() + self.route_cache + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .len() } // ----------------------------------------------------------------------- @@ -888,32 +1186,42 @@ impl PluginManager { /// Whether any plugins are registered for the given hook name. pub fn has_hooks_for(&self, hook_name: &str) -> bool { - self.registry.has_hooks_for(&HookType::new(hook_name)) + self.load_runtime() + .registry + .has_hooks_for(&HookType::new(hook_name)) } - /// Look up a plugin by name. - pub fn get_plugin(&self, name: &str) -> Option<&PluginRef> { - self.registry.get(name) + /// Look up a plugin by name. Returns an `Arc` clone — works + /// with the snapshot-based dispatch model where the registry sits + /// behind a transient `Arc` guard. `Arc` + /// derefs to `PluginRef`, so callers can chain methods directly: + /// `mgr.get_plugin("name").unwrap().is_disabled()` still compiles. + pub fn get_plugin(&self, name: &str) -> Option> { + self.load_runtime().registry.get(name) } /// Total number of registered plugins. pub fn plugin_count(&self) -> usize { - self.registry.plugin_count() + self.load_runtime().registry.plugin_count() } - /// All registered plugin names. - pub fn plugin_names(&self) -> Vec<&str> { - self.registry.plugin_names() + /// All registered plugin names (owned, not borrowed from the registry). + pub fn plugin_names(&self) -> Vec { + self.load_runtime().registry.plugin_names() } /// Whether the manager has been initialized. pub fn is_initialized(&self) -> bool { - self.initialized + self.initialized.load(Ordering::Acquire) } /// Unregister a plugin by name. - pub fn unregister(&mut self, name: &str) -> Option { - self.registry.unregister(name) + pub fn unregister(&self, name: &str) -> Option> { + let removed = self.mutate_runtime(|snap| snap.registry.unregister(name)); + if removed.is_some() { + self.clear_routing_cache(); + } + removed } } @@ -960,9 +1268,15 @@ mod tests { #[async_trait] impl Plugin for AllowPlugin { - fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } } impl HookHandler for AllowPlugin { @@ -983,9 +1297,15 @@ mod tests { #[async_trait] impl Plugin for DenyPlugin { - fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } } impl HookHandler for DenyPlugin { @@ -1009,15 +1329,15 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { - Err(PluginError::Execution { + ) -> Result, Box> { + Err(Box::new(PluginError::Execution { plugin_name: "error-plugin".into(), message: "simulated failure".into(), source: None, code: None, details: std::collections::HashMap::new(), proto_error_code: None, - }) + })) } fn hook_type_name(&self) -> &'static str { @@ -1054,11 +1374,20 @@ mod tests { } } + fn make_config_with_conditions( + name: &str, + conditions: Vec, + ) -> PluginConfig { + let mut cfg = make_config(name, 10, PluginMode::Sequential); + cfg.conditions = conditions; + cfg + } + // -- Tests -- #[tokio::test] async fn test_manager_lifecycle() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); assert!(!mgr.is_initialized()); assert_eq!(mgr.plugin_count(), 0); @@ -1079,7 +1408,6 @@ mod tests { value: "test".into(), }); - let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; @@ -1090,9 +1418,11 @@ mod tests { #[tokio::test] async fn test_invoke_by_name_allow() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); // Clean registration — no AnyHookHandler needed mgr.register_handler::(plugin, config).unwrap(); @@ -1102,7 +1432,6 @@ mod tests { value: "test".into(), }); - let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; @@ -1112,9 +1441,11 @@ mod tests { #[tokio::test] async fn test_invoke_by_name_deny() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("deny-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1123,7 +1454,6 @@ mod tests { value: "test".into(), }); - let (result, _) = mgr .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; @@ -1134,9 +1464,11 @@ mod tests { #[tokio::test] async fn test_invoke_typed() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1145,7 +1477,6 @@ mod tests { value: "typed".into(), }; - let (result, _) = mgr .invoke::(payload, Extensions::default(), None) .await; @@ -1157,9 +1488,11 @@ mod tests { async fn test_invoke_named() { // invoke_named::(hook_name, ...) gives compile-time payload // type checking while routing to a specific hook name. - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1180,9 +1513,11 @@ mod tests { #[tokio::test] async fn test_invoke_named_no_plugins_for_hook() { // invoke_named with a hook name that has no registered plugins - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("allow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1202,9 +1537,11 @@ mod tests { #[tokio::test] async fn test_invoke_named_deny() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("deny-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); @@ -1223,108 +1560,392 @@ mod tests { #[tokio::test] async fn test_has_hooks_for() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); assert!(!mgr.has_hooks_for("test_hook")); let config = make_config("p1", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); assert!(mgr.has_hooks_for("test_hook")); assert!(!mgr.has_hooks_for("other_hook")); } + /// When `routing_enabled` is `false` (the legacy / default mode), + /// each plugin's `conditions:` must be evaluated per request — a + /// non-matching condition should keep the plugin from firing. + /// Mirrors the Python implementation's per-plugin filtering. #[tokio::test] - async fn test_unregister() { - let mut mgr = PluginManager::default(); - let config = make_config("removable", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); - mgr.register_handler::(plugin, config).unwrap(); + async fn test_conditions_filter_plugins_when_routing_disabled() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; - assert_eq!(mgr.plugin_count(), 1); - mgr.unregister("removable"); - assert_eq!(mgr.plugin_count(), 0); - assert!(!mgr.has_hooks_for("test_hook")); - } + let counts: StdArc<[AtomicUsize; 2]> = + StdArc::new([AtomicUsize::new(0), AtomicUsize::new(0)]); - #[tokio::test] - async fn test_audit_plugin_cannot_block() { - let mut mgr = PluginManager::default(); - let config = make_config("audit-denier", 10, PluginMode::Audit); - let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + struct CountingHandler { + idx: usize, + counts: StdArc<[AtomicUsize; 2]>, + } + #[async_trait] + impl AnyHookHandler for CountingHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + self.counts[self.idx].fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } - mgr.register_handler::(plugin, config).unwrap(); - mgr.initialize().await.unwrap(); + let mgr = PluginManager::default(); - let payload: Box = Box::new(TestPayload { - value: "test".into(), + // Plugin A: condition requires tool == "wanted_tool" — fires for matching requests. + let mut tools = std::collections::HashSet::new(); + tools.insert("wanted_tool".to_string()); + let cfg_a = make_config_with_conditions( + "plugin_a", + vec![crate::plugin::PluginCondition { + tools: Some(tools), + ..Default::default() + }], + ); + let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() }); + let handler_a: Arc = Arc::new(CountingHandler { + idx: 0, + counts: StdArc::clone(&counts), + }); + mgr.register_raw::(plugin_a, cfg_a, handler_a) + .unwrap(); + + // Plugin B: empty conditions — fires unconditionally. + let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential); + let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() }); + let handler_b: Arc = Arc::new(CountingHandler { + idx: 1, + counts: StdArc::clone(&counts), }); + mgr.register_raw::(plugin_b, cfg_b, handler_b) + .unwrap(); + mgr.initialize().await.unwrap(); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; + // Request 1: tool=wanted_tool → both A and B should fire. + let ext_match = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("wanted_tool".into()), + ..Default::default() + })), + ..Default::default() + }; + let p: Box = Box::new(TestPayload { value: "1".into() }); + let _ = mgr.invoke_by_name("test_hook", p, ext_match, None).await; + assert_eq!( + counts[0].load(Ordering::SeqCst), + 1, + "plugin_a should fire on matching tool" + ); + assert_eq!( + counts[1].load(Ordering::SeqCst), + 1, + "plugin_b should fire (no conditions)" + ); - // Audit mode — deny is suppressed, pipeline continues - assert!(result.continue_processing); + // Request 2: tool=other_tool → only B fires (A's condition rejects). + let ext_no_match = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("other_tool".into()), + ..Default::default() + })), + ..Default::default() + }; + let p: Box = Box::new(TestPayload { value: "2".into() }); + let _ = mgr.invoke_by_name("test_hook", p, ext_no_match, None).await; + assert_eq!( + counts[0].load(Ordering::SeqCst), + 1, + "plugin_a should NOT fire on non-matching tool" + ); + assert_eq!( + counts[1].load(Ordering::SeqCst), + 2, + "plugin_b should fire on every request" + ); } + /// `user_patterns` glob matches against `extensions.security.subject.id`. + /// Specifically: pattern `admin-*` matches `admin-alice` but not `user-bob`. #[tokio::test] - async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() { - let mut mgr = PluginManager::default(); + async fn test_conditions_user_patterns_glob_filters() { + use std::sync::atomic::{AtomicUsize, Ordering}; - // Register an error handler with on_error: Disable - let config = make_config_with_on_error( - "flaky-plugin", 10, PluginMode::Sequential, OnError::Disable, - ); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); - let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler).unwrap(); + static FIRED: AtomicUsize = AtomicUsize::new(0); + FIRED.store(0, Ordering::SeqCst); - // Also register a normal allow plugin (lower priority = runs second) - let config2 = make_config("allow-plugin", 20, PluginMode::Sequential); - let plugin2 = Arc::new(AllowPlugin { cfg: config2.clone() }); - mgr.register_handler::(plugin2, config2).unwrap(); + struct CountHandler; + #[async_trait] + impl AnyHookHandler for CountHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + FIRED.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + let mgr = PluginManager::default(); + let cfg = make_config_with_conditions( + "admin_only", + vec![crate::plugin::PluginCondition { + user_patterns: Some(vec!["admin-*".to_string()]), + ..Default::default() + }], + ); + let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() }); + let handler: Arc = Arc::new(CountHandler); + mgr.register_raw::(plugin, cfg, handler).unwrap(); mgr.initialize().await.unwrap(); + let ext_with_user = |id: &str| Extensions { + security: Some(std::sync::Arc::new(crate::extensions::SecurityExtension { + subject: Some(crate::extensions::security::SubjectExtension { + id: Some(id.to_string()), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; - // First invocation — flaky plugin errors, gets disabled, pipeline continues - // because on_error is Disable (not Fail). allow-plugin still runs. - let payload: Box = Box::new(TestPayload { value: "first".into() }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; - assert!(result.continue_processing); - - // Verify the plugin is now disabled - let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); - assert!(plugin_ref.is_disabled()); - assert_eq!(plugin_ref.mode(), PluginMode::Disabled); + let p: Box = Box::new(TestPayload { value: "1".into() }); + let _ = mgr + .invoke_by_name("test_hook", p, ext_with_user("admin-alice"), None) + .await; + assert_eq!( + FIRED.load(Ordering::SeqCst), + 1, + "admin-alice should match admin-*" + ); - // Second invocation — flaky plugin should be skipped entirely - // (group_by_mode filters it out). Only allow-plugin runs. - let payload2: Box = Box::new(TestPayload { value: "second".into() }); - let (result2, _) = mgr.invoke_by_name("test_hook", payload2, Extensions::default(), None).await; - assert!(result2.continue_processing); + let p: Box = Box::new(TestPayload { value: "2".into() }); + let _ = mgr + .invoke_by_name("test_hook", p, ext_with_user("user-bob"), None) + .await; + assert_eq!( + FIRED.load(Ordering::SeqCst), + 1, + "user-bob should NOT match admin-*" + ); } #[tokio::test] - async fn test_on_error_ignore_continues_without_disabling() { - let mut mgr = PluginManager::default(); + async fn test_unregister() { + let mgr = PluginManager::default(); + let config = make_config("removable", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + mgr.register_handler::(plugin, config).unwrap(); - // Register an error handler with on_error: Ignore - let config = make_config_with_on_error( - "flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore, - ); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); - let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler).unwrap(); + assert_eq!(mgr.plugin_count(), 1); + mgr.unregister("removable"); + assert_eq!(mgr.plugin_count(), 0); + assert!(!mgr.has_hooks_for("test_hook")); + } + + /// Wraps the manager in `Arc` and dispatches concurrently from many + /// tasks. Also issues a `register_handler` call mid-flight to prove + /// that runtime registration is safe alongside invocations — the whole + /// point of the `ArcSwap`-based snapshot redesign. Before this fix, + /// `register_*` was `&mut self`, so this pattern wouldn't even compile. + #[tokio::test] + async fn test_manager_arc_shareable_with_concurrent_dispatch_and_registration() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static INVOKE_COUNT: AtomicUsize = AtomicUsize::new(0); + INVOKE_COUNT.store(0, Ordering::SeqCst); + + struct CountingHandler; + #[async_trait] + impl AnyHookHandler for CountingHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + INVOKE_COUNT.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = Arc::new(PluginManager::default()); + + // Register an initial plugin and initialize. + let cfg = make_config("p0", 10, PluginMode::Sequential); + let plugin: Arc = Arc::new(AllowPlugin { cfg: cfg.clone() }); + let handler: Arc = Arc::new(CountingHandler); + mgr.register_raw::(plugin, cfg, handler).unwrap(); + mgr.initialize().await.unwrap(); + + // Spawn N concurrent invokers; midway, register a second plugin + // from a different task — the snapshot swaps under their feet. + let n = 16; + let mut handles = Vec::with_capacity(n + 1); + for i in 0..n { + let mgr = Arc::clone(&mgr); + handles.push(tokio::spawn(async move { + let payload: Box = Box::new(TestPayload { + value: format!("call-{}", i), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + })); + } + + // Concurrent registration — proves register_handler works through &Arc. + { + let mgr = Arc::clone(&mgr); + handles.push(tokio::spawn(async move { + let cfg = make_config("p1-late", 20, PluginMode::Sequential); + let plugin: Arc = Arc::new(AllowPlugin { cfg: cfg.clone() }); + let handler: Arc = Arc::new(CountingHandler); + mgr.register_raw::(plugin, cfg, handler).unwrap(); + })); + } + + for h in handles { + h.await.unwrap(); + } + + // At least the initial plugin ran for every invoke (some invokes + // may have raced past the registration and only seen the initial + // plugin; others may have seen both). The exact count depends on + // the race, but lower bound is `n` (one fire per invoke for p0). + assert!(INVOKE_COUNT.load(Ordering::SeqCst) >= n); + // Late registration is now visible. + assert_eq!(mgr.plugin_count(), 2); + } + + #[tokio::test] + async fn test_audit_plugin_cannot_block() { + let mgr = PluginManager::default(); + let config = make_config("audit-denier", 10, PluginMode::Audit); + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Audit mode — deny is suppressed, pipeline continues + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() { + let mgr = PluginManager::default(); + + // Register an error handler with on_error: Disable + let config = + make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + // Also register a normal allow plugin (lower priority = runs second) + let config2 = make_config("allow-plugin", 20, PluginMode::Sequential); + let plugin2 = Arc::new(AllowPlugin { + cfg: config2.clone(), + }); + mgr.register_handler::(plugin2, config2) + .unwrap(); mgr.initialize().await.unwrap(); + // First invocation — flaky plugin errors, gets disabled, pipeline continues + // because on_error is Disable (not Fail). allow-plugin still runs. + let payload: Box = Box::new(TestPayload { + value: "first".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + + // Verify the plugin is now disabled + let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); + assert!(plugin_ref.is_disabled()); + assert_eq!(plugin_ref.mode(), PluginMode::Disabled); + + // Second invocation — flaky plugin should be skipped entirely + // (group_by_mode filters it out). Only allow-plugin runs. + let payload2: Box = Box::new(TestPayload { + value: "second".into(), + }); + let (result2, _) = mgr + .invoke_by_name("test_hook", payload2, Extensions::default(), None) + .await; + assert!(result2.continue_processing); + } + + #[tokio::test] + async fn test_on_error_ignore_continues_without_disabling() { + let mgr = PluginManager::default(); + + // Register an error handler with on_error: Ignore + let config = + make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); // First invocation — plugin errors, ignored, pipeline continues - let payload: Box = Box::new(TestPayload { value: "test".into() }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; assert!(result.continue_processing); // Plugin should NOT be disabled — still in its original mode @@ -1333,24 +1954,91 @@ mod tests { assert_eq!(plugin_ref.mode(), PluginMode::Sequential); } + /// Errors from `on_error: ignore` plugins must surface in + /// `PipelineResult.errors` so callers can see swallowed failures + /// programmatically — not just in log output. #[tokio::test] - async fn test_on_error_fail_halts_pipeline() { - let mut mgr = PluginManager::default(); + async fn test_on_error_ignore_records_in_pipeline_errors() { + let mgr = PluginManager::default(); + let config = + make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); - // Register an error handler with on_error: Fail (default) - let config = make_config_with_on_error( - "strict-plugin", 10, PluginMode::Sequential, OnError::Fail, + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Pipeline continued (Ignore policy)… + assert!(result.continue_processing); + // …but the swallowed error is in result.errors with structured fields. + assert_eq!(result.errors.len(), 1, "expected one error record"); + let rec = &result.errors[0]; + assert_eq!(rec.plugin_name, "error-plugin"); + assert!( + rec.message.contains("simulated failure"), + "message lost: {}", + rec.message, ); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + } + + /// Errors from `on_error: disable` plugins must ALSO appear in + /// `PipelineResult.errors` (not just trip the circuit breaker). + #[tokio::test] + async fn test_on_error_disable_records_in_pipeline_errors() { + let mgr = PluginManager::default(); + let config = + make_config_with_on_error("flaky-plugin", 10, PluginMode::Sequential, OnError::Disable); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); let handler: Arc = Arc::new(ErrorHandler); - mgr.register_raw::(plugin, config, handler).unwrap(); + mgr.register_raw::(plugin, config, handler) + .unwrap(); mgr.initialize().await.unwrap(); + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + assert_eq!(result.errors.len(), 1); + // Plugin was also disabled (the Disable policy's other effect). + assert!(mgr.get_plugin("flaky-plugin").unwrap().is_disabled()); + } + + #[tokio::test] + async fn test_on_error_fail_halts_pipeline() { + let mgr = PluginManager::default(); + + // Register an error handler with on_error: Fail (default) + let config = + make_config_with_on_error("strict-plugin", 10, PluginMode::Sequential, OnError::Fail); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); // Invocation — plugin errors, pipeline halts with a violation - let payload: Box = Box::new(TestPayload { value: "test".into() }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; assert!(!result.continue_processing); assert_eq!(result.violation.as_ref().unwrap().code, "plugin_error"); assert_eq!( @@ -1368,9 +2056,15 @@ mod tests { #[async_trait] impl Plugin for TransformPlugin { - fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } } impl HookHandler for TransformPlugin { @@ -1398,7 +2092,7 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await; let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) @@ -1413,23 +2107,95 @@ mod tests { #[tokio::test] async fn test_transform_modifies_payload() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("transformer", 10, PluginMode::Transform); - let plugin = Arc::new(TransformPlugin { cfg: config.clone() }); + let plugin = Arc::new(TransformPlugin { + cfg: config.clone(), + }); mgr.register_handler::(plugin, config).unwrap(); mgr.initialize().await.unwrap(); - let payload = TestPayload { value: "original".into() }; + let payload = TestPayload { + value: "original".into(), + }; - let (result, _) = mgr.invoke::(payload, Extensions::default(), None).await; + let (result, _) = mgr + .invoke::(payload, Extensions::default(), None) + .await; assert!(result.continue_processing); let final_payload = result.modified_payload.unwrap(); - let typed = final_payload.as_any().downcast_ref::().unwrap(); + let typed = final_payload + .as_any() + .downcast_ref::() + .unwrap(); assert_eq!(typed.value, "original_transformed"); } + /// Transform phase is documented `can_block: No` (plugin.rs PluginMode + /// table). An `on_error: Fail` plugin error or timeout in Transform must + /// NOT halt the pipeline — non-blocking is non-blocking, regardless of + /// the plugin's stated on_error preference. Disable still works. + #[tokio::test] + async fn test_transform_on_error_fail_does_not_halt_pipeline() { + let mgr = PluginManager::default(); + let config = + make_config_with_on_error("flaky-transform", 10, PluginMode::Transform, OnError::Fail); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!( + result.continue_processing, + "Transform on_error:Fail must not halt the pipeline (phase is non-blocking)", + ); + assert!(result.violation.is_none()); + } + + /// Audit phase previously ignored `on_error` entirely, so an + /// `on_error: Disable` plugin would error forever without the circuit + /// breaker tripping. After the fix Audit honors Disable. + #[tokio::test] + async fn test_audit_on_error_disable_disables_plugin() { + let mgr = PluginManager::default(); + let config = + make_config_with_on_error("flaky-audit", 10, PluginMode::Audit, OnError::Disable); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + assert!(!mgr.get_plugin("flaky-audit").unwrap().is_disabled()); + + // Invoke once — handler errors, on_error=Disable, plugin must be + // disabled. Pipeline still returns success (Audit can't block). + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + + assert!( + mgr.get_plugin("flaky-audit").unwrap().is_disabled(), + "Audit phase must honor on_error:Disable", + ); + } + #[tokio::test] async fn test_concurrent_multiple_plugins_all_run() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1447,7 +2213,7 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { // Small sleep to ensure both tasks are spawned before either finishes tokio::time::sleep(std::time::Duration::from_millis(50)).await; CALL_COUNT.fetch_add(1, Ordering::SeqCst); @@ -1460,7 +2226,7 @@ mod tests { } } - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let c1 = make_config("concurrent-1", 10, PluginMode::Concurrent); let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); @@ -1475,95 +2241,512 @@ mod tests { mgr.initialize().await.unwrap(); let start = std::time::Instant::now(); - let payload: Box = Box::new(TestPayload { value: "test".into() }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; let elapsed = start.elapsed(); assert!(result.continue_processing); assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2); // If they ran in parallel, total time should be ~50ms, not ~100ms - assert!(elapsed.as_millis() < 90, "concurrent plugins ran serially: {}ms", elapsed.as_millis()); - } - - #[tokio::test] - async fn test_timeout_fires_on_slow_handler() { - // Create a manager with a very short timeout - let config = ManagerConfig { - executor: crate::executor::ExecutorConfig { - timeout_seconds: 1, - short_circuit_on_deny: true, - }, - }; - let mut mgr = PluginManager::new(config); - - // Register a handler that sleeps longer than the timeout - let plugin_config = make_config("slow-plugin", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: plugin_config.clone() }); - let handler: Arc = Arc::new(SlowHandler { delay_ms: 5000 }); - mgr.register_raw::(plugin, plugin_config, handler).unwrap(); - - mgr.initialize().await.unwrap(); - - let start = std::time::Instant::now(); - let payload: Box = Box::new(TestPayload { value: "test".into() }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; - let elapsed = start.elapsed(); - - // Should have timed out and denied (on_error: Fail) - assert!(!result.continue_processing); - assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout"); - // Should have returned in ~1s, not 5s - assert!(elapsed.as_secs() < 3, "timeout didn't fire: {}s", elapsed.as_secs()); + assert!( + elapsed.as_millis() < 90, + "concurrent plugins ran serially: {}ms", + elapsed.as_millis() + ); } + /// A deny on one concurrent plugin should short-circuit the pipeline + /// AND cancel the slow plugin still running in another task. Previously + /// `join_all` waited for every task before noticing the deny, so + /// short_circuit_on_deny was a no-op in wall-clock terms and the slow + /// plugin completed its side effects after the pipeline returned. #[tokio::test] - async fn test_fire_and_forget_returns_before_task_completes() { - use std::sync::atomic::{AtomicBool, Ordering}; + async fn test_concurrent_short_circuit_aborts_slow_plugin() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; - static TASK_COMPLETED: AtomicBool = AtomicBool::new(false); - TASK_COMPLETED.store(false, Ordering::SeqCst); + static SLOW_COMPLETED: AtomicUsize = AtomicUsize::new(0); + SLOW_COMPLETED.store(0, Ordering::SeqCst); - struct SlowFireAndForgetHandler; + struct DenyImmediately; + #[async_trait] + impl AnyHookHandler for DenyImmediately { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + let result: PluginResult = + PluginResult::deny(PluginViolation::new("denied", "fast deny")); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + struct SlowSideEffect; #[async_trait] - impl AnyHookHandler for SlowFireAndForgetHandler { + impl AnyHookHandler for SlowSideEffect { async fn invoke( &self, _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - TASK_COMPLETED.store(true, Ordering::SeqCst); + ) -> Result, Box> { + tokio::time::sleep(Duration::from_secs(2)).await; + // If the task isn't aborted at the sleep's await point, + // this fetch_add fires after the pipeline already returned. + SLOW_COMPLETED.fetch_add(1, Ordering::SeqCst); let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) } - fn hook_type_name(&self) -> &'static str { "test_hook" } } - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); - let config = make_config("fire-forget", 10, PluginMode::FireAndForget); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); - let handler: Arc = Arc::new(SlowFireAndForgetHandler); - mgr.register_raw::(plugin, config, handler).unwrap(); + let cfg_deny = make_config("denier", 10, PluginMode::Concurrent); + let plugin_deny = Arc::new(AllowPlugin { + cfg: cfg_deny.clone(), + }); + mgr.register_raw::( + plugin_deny, + cfg_deny, + Arc::new(DenyImmediately) as Arc, + ) + .unwrap(); + + let cfg_slow = make_config("slow", 20, PluginMode::Concurrent); + let plugin_slow = Arc::new(AllowPlugin { + cfg: cfg_slow.clone(), + }); + mgr.register_raw::( + plugin_slow, + cfg_slow, + Arc::new(SlowSideEffect) as Arc, + ) + .unwrap(); mgr.initialize().await.unwrap(); - let payload: Box = Box::new(TestPayload { value: "test".into() }); - let (result, bg) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + // Pipeline must return quickly — the deny short-circuits before + // the 2s sleep completes. + let start = std::time::Instant::now(); + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + let elapsed = start.elapsed(); - // Pipeline should return immediately — before the background task finishes + assert!(!result.continue_processing); + assert!( + elapsed < Duration::from_millis(500), + "pipeline should short-circuit on deny, but took {}ms (slow plugin not aborted)", + elapsed.as_millis(), + ); + + // Wait long enough that the slow plugin's sleep would have finished + // if it hadn't been aborted, then verify its side effect didn't fire. + tokio::time::sleep(Duration::from_millis(2_500)).await; + assert_eq!( + SLOW_COMPLETED.load(Ordering::SeqCst), + 0, + "slow plugin's side effect ran after pipeline returned — task was not aborted", + ); + } + + /// short_circuit_on_deny=false: every concurrent plugin must run to + /// completion (no abort), and the earliest deny is returned at the end. + #[tokio::test] + async fn test_concurrent_no_short_circuit_runs_every_plugin() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static ALLOW_RAN: AtomicUsize = AtomicUsize::new(0); + ALLOW_RAN.store(0, Ordering::SeqCst); + + struct DenyImmediately; + #[async_trait] + impl AnyHookHandler for DenyImmediately { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + let result: PluginResult = + PluginResult::deny(PluginViolation::new("denied", "fast deny")); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + struct AllowAndCount; + #[async_trait] + impl AnyHookHandler for AllowAndCount { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + ALLOW_RAN.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let config = ManagerConfig { + executor: crate::executor::ExecutorConfig { + timeout_seconds: 30, + short_circuit_on_deny: false, + }, + route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES, + }; + let mgr = PluginManager::new(config); + + let cfg_deny = make_config("denier", 10, PluginMode::Concurrent); + let plugin_deny = Arc::new(AllowPlugin { + cfg: cfg_deny.clone(), + }); + mgr.register_raw::( + plugin_deny, + cfg_deny, + Arc::new(DenyImmediately) as Arc, + ) + .unwrap(); + + let cfg_allow = make_config("allow", 20, PluginMode::Concurrent); + let plugin_allow = Arc::new(AllowPlugin { + cfg: cfg_allow.clone(), + }); + mgr.register_raw::( + plugin_allow, + cfg_allow, + Arc::new(AllowAndCount) as Arc, + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Earliest deny is returned… + assert!(!result.continue_processing); + // …but the non-denying plugin must still have run (no abort). + assert_eq!(ALLOW_RAN.load(Ordering::SeqCst), 1); + } + + /// Plugin handler that panics inside its async invoke. With tokio::spawn, + /// the panic surfaces as a JoinError on the task's JoinHandle. + struct PanicHandler; + + #[async_trait] + impl AnyHookHandler for PanicHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + panic!("simulated panic in concurrent plugin task"); + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + /// A panicking concurrent plugin with `on_error: Fail` must halt the + /// pipeline with a violation. Previously the JoinError was just logged + /// and the panic was silently swallowed. + /// + /// Note: this test prints "thread 'tokio-runtime-worker' panicked at..." + /// to stderr — that's tokio reporting the captured panic. Expected. + #[tokio::test] + async fn test_concurrent_panic_with_on_error_fail_halts_pipeline() { + let mgr = PluginManager::default(); + + let cfg = + make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Fail); + let plugin = Arc::new(AllowPlugin { cfg: cfg.clone() }); + let handler: Arc = Arc::new(PanicHandler); + mgr.register_raw::(plugin, cfg, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!( + !result.continue_processing, + "Fail must halt the pipeline on panic" + ); + let v = result.violation.as_ref().expect("expected violation"); + assert_eq!(v.code, "plugin_panic"); + assert_eq!(v.plugin_name.as_deref(), Some("panic-plugin")); + } + + /// A panicking concurrent plugin with `on_error: Disable` must trip + /// the plugin's circuit breaker so it's skipped on subsequent invokes. + /// A second non-panicking plugin in the same phase still runs. + #[tokio::test] + async fn test_concurrent_panic_with_on_error_disable_trips_circuit_breaker() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static SURVIVOR_CALLS: AtomicUsize = AtomicUsize::new(0); + SURVIVOR_CALLS.store(0, Ordering::SeqCst); + + struct SurvivorHandler; + #[async_trait] + impl AnyHookHandler for SurvivorHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + SURVIVOR_CALLS.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + let panic_cfg = + make_config_with_on_error("panic-plugin", 10, PluginMode::Concurrent, OnError::Disable); + let panic_plugin = Arc::new(AllowPlugin { + cfg: panic_cfg.clone(), + }); + let panic_handler: Arc = Arc::new(PanicHandler); + mgr.register_raw::(panic_plugin, panic_cfg, panic_handler) + .unwrap(); + + let survivor_cfg = make_config("survivor", 20, PluginMode::Concurrent); + let survivor_plugin = Arc::new(AllowPlugin { + cfg: survivor_cfg.clone(), + }); + let survivor_handler: Arc = Arc::new(SurvivorHandler); + mgr.register_raw::(survivor_plugin, survivor_cfg, survivor_handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // First invoke — panic plugin panics, gets disabled. Survivor still runs. + let payload: Box = Box::new(TestPayload { value: "1".into() }); + let (result1, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!( + result1.continue_processing, + "Disable must not halt the pipeline" + ); + assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 1); + assert!( + mgr.get_plugin("panic-plugin").unwrap().is_disabled(), + "panic plugin must be disabled after the panic", + ); + + // Second invoke — disabled plugin is skipped, doesn't panic again. + let payload2: Box = Box::new(TestPayload { value: "2".into() }); + let (result2, _) = mgr + .invoke_by_name("test_hook", payload2, Extensions::default(), None) + .await; + assert!(result2.continue_processing); + // Survivor ran a second time; panic plugin did not. + assert_eq!(SURVIVOR_CALLS.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn test_timeout_fires_on_slow_handler() { + // Create a manager with a very short timeout + let config = ManagerConfig { + executor: crate::executor::ExecutorConfig { + timeout_seconds: 1, + short_circuit_on_deny: true, + }, + route_cache_max_entries: DEFAULT_ROUTE_CACHE_MAX_ENTRIES, + }; + let mgr = PluginManager::new(config); + + // Register a handler that sleeps longer than the timeout + let plugin_config = make_config("slow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: plugin_config.clone(), + }); + let handler: Arc = Arc::new(SlowHandler { delay_ms: 5000 }); + mgr.register_raw::(plugin, plugin_config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let start = std::time::Instant::now(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + let elapsed = start.elapsed(); + + // Should have timed out and denied (on_error: Fail) + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout"); + // Should have returned in ~1s, not 5s + assert!( + elapsed.as_secs() < 3, + "timeout didn't fire: {}s", + elapsed.as_secs() + ); + } + + #[tokio::test] + async fn test_fire_and_forget_returns_before_task_completes() { + use std::sync::atomic::{AtomicBool, Ordering}; + + static TASK_COMPLETED: AtomicBool = AtomicBool::new(false); + TASK_COMPLETED.store(false, Ordering::SeqCst); + + struct SlowFireAndForgetHandler; + + #[async_trait] + impl AnyHookHandler for SlowFireAndForgetHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + TASK_COMPLETED.store(true, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + let config = make_config("fire-forget", 10, PluginMode::FireAndForget); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(SlowFireAndForgetHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, bg) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Pipeline should return immediately — before the background task finishes assert!(result.continue_processing); - assert!(!TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task completed before pipeline returned"); + assert!( + !TASK_COMPLETED.load(Ordering::SeqCst), + "fire-and-forget task completed before pipeline returned" + ); // Wait for background tasks using wait_for_background_tasks() let errors = bg.wait_for_background_tasks().await; - assert!(errors.is_empty(), "background task had errors: {:?}", errors); - assert!(TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task never completed"); + assert!( + errors.is_empty(), + "background task had errors: {:?}", + errors + ); + assert!( + TASK_COMPLETED.load(Ordering::SeqCst), + "fire-and-forget task never completed" + ); + } + + /// `shutdown()` must wait for in-flight fire-and-forget tasks to drain + /// before returning, so audit / telemetry plugins that flush at the + /// end of a request lifetime aren't cancelled mid-write. The caller + /// drops `BackgroundTasks` (the common case for fire-and-forget), + /// so the only way the manager knows about the in-flight task is the + /// internal `TaskTracker`. + #[tokio::test] + async fn test_shutdown_drains_in_flight_fire_and_forget_tasks() { + use std::sync::atomic::{AtomicBool, Ordering}; + + static FAF_COMPLETED: AtomicBool = AtomicBool::new(false); + FAF_COMPLETED.store(false, Ordering::SeqCst); + + struct SlowFafHandler; + #[async_trait] + impl AnyHookHandler for SlowFafHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + FAF_COMPLETED.store(true, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + let config = make_config("slow-faf", 10, PluginMode::FireAndForget); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(SlowFafHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + mgr.initialize().await.unwrap(); + + // Invoke and drop BackgroundTasks immediately — simulating the + // common case where the caller doesn't explicitly wait for FAF. + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (_result, _bg_dropped) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + // Task should still be in flight (sleeping 150ms). + assert!(!FAF_COMPLETED.load(Ordering::SeqCst)); + + // shutdown() must drain in-flight FAF tasks before returning. + mgr.shutdown().await; + + // After shutdown, the FAF task must have run to completion. + assert!( + FAF_COMPLETED.load(Ordering::SeqCst), + "shutdown returned before fire-and-forget task finished — task was abandoned", + ); } #[tokio::test] @@ -1579,12 +2762,14 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { ctx.set_global("writer_was_here", serde_json::Value::Bool(true)); let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) } - fn hook_type_name(&self) -> &'static str { "test_hook" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } struct ReaderHandler { @@ -1597,101 +2782,660 @@ mod tests { &self, _payload: &dyn PluginPayload, _extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, PluginError> { - if ctx.get_global("writer_was_here").is_some() { - self.saw_writer.store(true, std::sync::atomic::Ordering::SeqCst); - } + ctx: &mut PluginContext, + ) -> Result, Box> { + if ctx.get_global("writer_was_here").is_some() { + self.saw_writer + .store(true, std::sync::atomic::Ordering::SeqCst); + } + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let mgr = PluginManager::default(); + + // Writer runs first (priority 10) + let c1 = make_config("writer", 10, PluginMode::Sequential); + let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); + let h1: Arc = Arc::new(WriterHandler); + mgr.register_raw::(p1, c1, h1).unwrap(); + + // Reader runs second (priority 20) + let c2 = make_config("reader", 20, PluginMode::Sequential); + let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); + let h2: Arc = Arc::new(ReaderHandler { + saw_writer: saw_writer.clone(), + }); + mgr.register_raw::(p2, c2, h2).unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + assert!( + saw_writer.load(std::sync::atomic::Ordering::SeqCst), + "reader plugin did not see writer's global_state change" + ); + } + + #[tokio::test] + async fn test_local_state_persists_across_hook_invocations() { + // Plugin writes to local_state on first hook call. + // Context table is threaded into second call — local_state preserved. + + struct LocalWriterHandler; + + #[async_trait] + impl AnyHookHandler for LocalWriterHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + // Increment a counter in local_state + let count = ctx + .get_local("call_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + ctx.set_local("call_count", serde_json::Value::from(count + 1)); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + let config = make_config("counter", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(LocalWriterHandler); + mgr.register_raw::(plugin, config, handler) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // First invocation — no context table, starts fresh + let payload: Box = Box::new(TestPayload { + value: "first".into(), + }); + let (result1, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result1.continue_processing); + + // Check call_count = 1 in the returned context table + let table = &result1.context_table; + let local = table + .local_states + .values() + .next() + .expect("context table should have one local_state entry"); + assert_eq!(local.get("call_count").unwrap().as_u64().unwrap(), 1); + + // Second invocation — pass the context table from the first call + let payload2: Box = Box::new(TestPayload { + value: "second".into(), + }); + let (result2, _) = mgr + .invoke_by_name( + "test_hook", + payload2, + Extensions::default(), + Some(result1.context_table), + ) + .await; + assert!(result2.continue_processing); + + // call_count should now be 2 — local_state persisted across invocations + let table2 = &result2.context_table; + let local2 = table2 + .local_states + .values() + .next() + .expect("context table should have one local_state entry"); + assert_eq!(local2.get("call_count").unwrap().as_u64().unwrap(), 2); + } + + /// global_state writes by an earlier plugin must be visible to a later + /// plugin in the same serial phase, and the canonical state on the + /// returned context_table must reflect every plugin's contribution in + /// priority order. Previously this relied on `ctx_table.values().last()` + /// (HashMap iteration order — non-deterministic). + #[tokio::test] + async fn test_global_state_propagates_in_priority_order() { + /// Handler that appends `tag` to global_state["chain"] (creating + /// an array if absent). After running, the array reveals the + /// observed run order from each plugin's perspective. + struct GlobalChainHandler { + tag: &'static str, + } + + #[async_trait] + impl AnyHookHandler for GlobalChainHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + let mut chain = ctx + .get_global("chain") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + chain.push(serde_json::Value::String(self.tag.into())); + ctx.set_global("chain", serde_json::Value::Array(chain)); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mgr = PluginManager::default(); + + // Plugin A — priority 10 (runs first) + let cfg_a = make_config("plugin_a", 10, PluginMode::Sequential); + let plugin_a = Arc::new(AllowPlugin { cfg: cfg_a.clone() }); + let handler_a: Arc = Arc::new(GlobalChainHandler { tag: "a" }); + mgr.register_raw::(plugin_a, cfg_a, handler_a) + .unwrap(); + + // Plugin B — priority 20 (runs second) + let cfg_b = make_config("plugin_b", 20, PluginMode::Sequential); + let plugin_b = Arc::new(AllowPlugin { cfg: cfg_b.clone() }); + let handler_b: Arc = Arc::new(GlobalChainHandler { tag: "b" }); + mgr.register_raw::(plugin_b, cfg_b, handler_b) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "x".into() }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + assert!(result.continue_processing); + + // Canonical global_state on the returned table must contain both + // contributions in priority order — proving plugin B observed plugin + // A's write, and the table holds the merged result, not an arbitrary + // plugin's snapshot. + let chain = result + .context_table + .global_state + .get("chain") + .and_then(|v| v.as_array()) + .expect("global_state.chain should be an array"); + let tags: Vec<&str> = chain.iter().filter_map(|v| v.as_str()).collect(); + assert_eq!(tags, vec!["a", "b"]); + } + + /// All five phases (Sequential, Transform, Audit, Concurrent, + /// FireAndForget) execute in the documented order, with payload + /// modifications from earlier phases visible in later ones. Closes + /// the review's "no multi-phase combination test" gap. + #[tokio::test] + async fn test_all_five_phases_run_in_order_with_payload_chaining() { + use std::sync::Arc as StdArc; + use std::sync::Mutex as StdMutex; + + let log: StdArc>> = StdArc::new(StdMutex::new(Vec::new())); + + // Sequential — modifies payload, logs "seq". + struct SeqHandler { + log: StdArc>>, + } + #[async_trait] + impl AnyHookHandler for SeqHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + self.log.lock().unwrap().push("seq"); + let typed = payload.as_any().downcast_ref::().unwrap(); + let modified = TestPayload { + value: format!("{}|seq", typed.value), + }; + let result: PluginResult = PluginResult::modify_payload(modified); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // Transform — modifies payload, logs "transform". + struct TransformLogger { + log: StdArc>>, + } + #[async_trait] + impl AnyHookHandler for TransformLogger { + async fn invoke( + &self, + payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + self.log.lock().unwrap().push("transform"); + let typed = payload.as_any().downcast_ref::().unwrap(); + let modified = TestPayload { + value: format!("{}|transform", typed.value), + }; + let result: PluginResult = PluginResult::modify_payload(modified); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // Logger that asserts the payload it observes contains both prior + // phases' marks (proving payload chaining made it this far). + struct ObserverHandler { + tag: &'static str, + log: StdArc>>, + expected_payload: &'static str, + } + #[async_trait] + impl AnyHookHandler for ObserverHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + let typed = payload.as_any().downcast_ref::().unwrap(); + assert_eq!( + typed.value, self.expected_payload, + "{} observed unexpected payload: got '{}', expected '{}'", + self.tag, typed.value, self.expected_payload, + ); + self.log.lock().unwrap().push(self.tag); let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) } - fn hook_type_name(&self) -> &'static str { "test_hook" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } - let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - - let mut mgr = PluginManager::default(); - - // Writer runs first (priority 10) - let c1 = make_config("writer", 10, PluginMode::Sequential); - let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); - let h1: Arc = Arc::new(WriterHandler); - mgr.register_raw::(p1, c1, h1).unwrap(); + let mgr = PluginManager::default(); - // Reader runs second (priority 20) - let c2 = make_config("reader", 20, PluginMode::Sequential); - let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); - let h2: Arc = Arc::new(ReaderHandler { saw_writer: saw_writer.clone() }); - mgr.register_raw::(p2, c2, h2).unwrap(); + let cfg_seq = make_config("seq", 10, PluginMode::Sequential); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_seq.clone(), + }), + cfg_seq, + Arc::new(SeqHandler { + log: StdArc::clone(&log), + }), + ) + .unwrap(); + + let cfg_transform = make_config("transform", 10, PluginMode::Transform); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_transform.clone(), + }), + cfg_transform, + Arc::new(TransformLogger { + log: StdArc::clone(&log), + }), + ) + .unwrap(); + + let cfg_audit = make_config("audit", 10, PluginMode::Audit); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_audit.clone(), + }), + cfg_audit, + Arc::new(ObserverHandler { + tag: "audit", + log: StdArc::clone(&log), + expected_payload: "start|seq|transform", + }), + ) + .unwrap(); + + let cfg_concurrent = make_config("concurrent", 10, PluginMode::Concurrent); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_concurrent.clone(), + }), + cfg_concurrent, + Arc::new(ObserverHandler { + tag: "concurrent", + log: StdArc::clone(&log), + expected_payload: "start|seq|transform", + }), + ) + .unwrap(); + + let cfg_faf = make_config("faf", 10, PluginMode::FireAndForget); + mgr.register_raw::( + Arc::new(AllowPlugin { + cfg: cfg_faf.clone(), + }), + cfg_faf, + Arc::new(ObserverHandler { + tag: "faf", + log: StdArc::clone(&log), + expected_payload: "start|seq|transform", + }), + ) + .unwrap(); mgr.initialize().await.unwrap(); - let payload: Box = Box::new(TestPayload { value: "test".into() }); - let (result, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let payload: Box = Box::new(TestPayload { + value: "start".into(), + }); + let (result, bg) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; assert!(result.continue_processing); + // Final payload should have both modify-phase marks. + let final_payload = result.modified_payload.unwrap(); + let typed = final_payload + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(typed.value, "start|seq|transform"); + + // Drain the FAF task before checking ordering — its log entry + // races the rest of the function otherwise. + let _ = bg.wait_for_background_tasks().await; + + let log = log.lock().unwrap(); + // Sequential, Transform, Audit are guaranteed in order (serial phases). + assert_eq!(log[0], "seq", "first should be sequential phase"); + assert_eq!(log[1], "transform", "second should be transform phase"); + assert_eq!(log[2], "audit", "third should be audit phase"); + // Concurrent runs before invoke returns; FAF was waited on above. + // Their relative order with each other is not strictly guaranteed + // (FAF spawns *after* concurrent finishes, but tokio scheduling + // can interleave). Just check both present in indices 3 / 4. + let post_audit: std::collections::HashSet<&&'static str> = log[3..].iter().collect(); assert!( - saw_writer.load(std::sync::atomic::Ordering::SeqCst), - "reader plugin did not see writer's global_state change" + post_audit.contains(&"concurrent"), + "concurrent phase must run" ); + assert!(post_audit.contains(&"faf"), "fire-and-forget must run"); + assert_eq!(log.len(), 5, "all five phases should have logged"); } + /// Routing must work for `resource`, `prompt`, and `llm` entity types + /// — not just `tool`. Closes the review's "no test verifying entity + /// types other than tool in routing" gap. #[tokio::test] - async fn test_local_state_persists_across_hook_invocations() { - // Plugin writes to local_state on first hook call. - // Context table is threaded into second call — local_state preserved. - - struct LocalWriterHandler; + async fn test_routing_works_for_all_entity_types() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + // One counter per entity-type test; each plugin only fires when + // the route resolves to it. + struct CountHandler { + counter: StdArc, + } #[async_trait] - impl AnyHookHandler for LocalWriterHandler { + impl AnyHookHandler for CountHandler { async fn invoke( &self, _payload: &dyn PluginPayload, _extensions: &Extensions, - ctx: &mut PluginContext, - ) -> Result, PluginError> { - // Increment a counter in local_state - let count = ctx.get_local("call_count") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - ctx.set_local("call_count", serde_json::Value::from(count + 1)); + _ctx: &mut PluginContext, + ) -> Result, Box> { + self.counter.fetch_add(1, Ordering::SeqCst); let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) } - fn hook_type_name(&self) -> &'static str { "test_hook" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } - let mut mgr = PluginManager::default(); + // Each row: (entity_type, route field name, route value, request entity_name, should_match) + // We build a fresh manager per entity type so routes don't bleed. + for (entity_type, route_field, route_value, request_name, should_match) in [ + ("resource", "resource", "my_resource", "my_resource", true), + ( + "resource", + "resource", + "my_resource", + "other_resource", + false, + ), + ("prompt", "prompt", "my_prompt", "my_prompt", true), + ("prompt", "prompt", "my_prompt", "other_prompt", false), + ("llm", "llm", "gpt-4", "gpt-4", true), + ("llm", "llm", "gpt-4", "claude", false), + ] { + let yaml = format!( + r#" +plugin_settings: + routing_enabled: true +plugins: + - name: target + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - {route_field}: {route_value} + plugins: + - target +"# + ); + let cpex_config = crate::config::parse_config(&yaml).unwrap(); + + let mgr = PluginManager::default(); + let counter = StdArc::new(AtomicUsize::new(0)); + // Custom factory that hands out a CountHandler with our shared counter. + struct ParamFactory(StdArc); + impl crate::factory::PluginFactory for ParamFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + Ok(crate::factory::PluginInstance { + plugin: Arc::new(AllowPlugin { + cfg: config.clone(), + }), + handlers: vec![( + "test_hook", + Arc::new(CountHandler { + counter: StdArc::clone(&self.0), + }), + )], + }) + } + } + mgr.register_factory( + "test/allow", + Box::new(ParamFactory(StdArc::clone(&counter))), + ); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + let p: Box = Box::new(TestPayload { value: "x".into() }); + let ext = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some(entity_type.into()), + entity_name: Some(request_name.into()), + ..Default::default() + })), + ..Default::default() + }; + let _ = mgr.invoke_by_name("test_hook", p, ext, None).await; - let config = make_config("counter", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); - let handler: Arc = Arc::new(LocalWriterHandler); - mgr.register_raw::(plugin, config, handler).unwrap(); + let expected = if should_match { 1 } else { 0 }; + assert_eq!( + counter.load(Ordering::SeqCst), + expected, + "entity_type={} route_field={} route_value={} request_name={} expected fire={}", + entity_type, + route_field, + route_value, + request_name, + should_match, + ); + } + } - mgr.initialize().await.unwrap(); + /// `initialize()` must roll back already-initialized plugins by + /// calling `shutdown()` on each, in reverse order, when a later + /// plugin's `initialize()` fails. Closes the review's "no test for + /// initialize() rollback path" gap. + #[tokio::test] + async fn test_initialize_rollback_on_failure() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + + // Track per-plugin init / shutdown invocations. + let init_count_a = StdArc::new(AtomicUsize::new(0)); + let shutdown_count_a = StdArc::new(AtomicUsize::new(0)); + let init_count_b = StdArc::new(AtomicUsize::new(0)); + let shutdown_count_b = StdArc::new(AtomicUsize::new(0)); + let init_count_c = StdArc::new(AtomicUsize::new(0)); + let shutdown_count_c = StdArc::new(AtomicUsize::new(0)); + + struct LifecyclePlugin { + cfg: PluginConfig, + init_counter: StdArc, + shutdown_counter: StdArc, + fail_init: bool, + } + #[async_trait] + impl Plugin for LifecyclePlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + self.init_counter.fetch_add(1, Ordering::SeqCst); + if self.fail_init { + Err(Box::new(PluginError::Config { + message: "intentional init failure".into(), + })) + } else { + Ok(()) + } + } + async fn shutdown(&self) -> Result<(), Box> { + self.shutdown_counter.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + impl HookHandler for LifecyclePlugin { + fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } + } - // First invocation — no context table, starts fresh - let payload: Box = Box::new(TestPayload { value: "first".into() }); - let (result1, _) = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; - assert!(result1.continue_processing); + let mgr = PluginManager::default(); - // Check call_count = 1 in the returned context table - let table = &result1.context_table; - let ctx = table.values().next().expect("context table should have one entry"); - assert_eq!(ctx.get_local("call_count").unwrap().as_u64().unwrap(), 1); + // Plugin A: initializes successfully (priority 10, registered first). + let cfg_a = make_config("a", 10, PluginMode::Sequential); + let plugin_a = Arc::new(LifecyclePlugin { + cfg: cfg_a.clone(), + init_counter: StdArc::clone(&init_count_a), + shutdown_counter: StdArc::clone(&shutdown_count_a), + fail_init: false, + }); + mgr.register_handler::(plugin_a, cfg_a) + .unwrap(); + + // Plugin B: initialize() returns Err — should trigger rollback. + let cfg_b = make_config("b", 20, PluginMode::Sequential); + let plugin_b = Arc::new(LifecyclePlugin { + cfg: cfg_b.clone(), + init_counter: StdArc::clone(&init_count_b), + shutdown_counter: StdArc::clone(&shutdown_count_b), + fail_init: true, + }); + mgr.register_handler::(plugin_b, cfg_b) + .unwrap(); + + // Plugin C: never reached (init aborts at B). + let cfg_c = make_config("c", 30, PluginMode::Sequential); + let plugin_c = Arc::new(LifecyclePlugin { + cfg: cfg_c.clone(), + init_counter: StdArc::clone(&init_count_c), + shutdown_counter: StdArc::clone(&shutdown_count_c), + fail_init: false, + }); + mgr.register_handler::(plugin_c, cfg_c) + .unwrap(); - // Second invocation — pass the context table from the first call - let payload2: Box = Box::new(TestPayload { value: "second".into() }); - let (result2, _) = mgr.invoke_by_name( - "test_hook", payload2, Extensions::default(), Some(result1.context_table), - ).await; - assert!(result2.continue_processing); + let result = mgr.initialize().await; + assert!( + result.is_err(), + "initialize() must propagate the init failure" + ); - // call_count should now be 2 — local_state persisted across invocations - let table2 = &result2.context_table; - let ctx2 = table2.values().next().expect("context table should have one entry"); - assert_eq!(ctx2.get_local("call_count").unwrap().as_u64().unwrap(), 2); + // The registry iterates plugins in `HashMap` order, which is + // randomized — so we don't know whether A and C were reached + // before B failed. The rollback invariants are order-independent: + // + // - For non-failing plugins (A, C): if init() was called, shutdown() + // must have been called too (rolled back). If init() was not + // called (B happened to iterate first), shutdown() shouldn't + // have either. In both cases, init_count == shutdown_count. + // - B's init() was called and failed, so its shutdown() must NOT + // run — failed-init plugins are not part of the rollback set. + let assert_pair_invariant = + |init: &AtomicUsize, shutdown: &AtomicUsize, tag: &str| { + let i = init.load(Ordering::SeqCst); + let s = shutdown.load(Ordering::SeqCst); + assert!( + (i == 0 && s == 0) || (i == 1 && s == 1), + "{}: init/shutdown should be paired (both 0 or both 1), got init={} shutdown={}", + tag, i, s, + ); + }; + assert_pair_invariant(&init_count_a, &shutdown_count_a, "A"); + assert_pair_invariant(&init_count_c, &shutdown_count_c, "C"); + + // B specifically: init was called and failed; no shutdown for it. + assert_eq!( + init_count_b.load(Ordering::SeqCst), + 1, + "B's initialize was called", + ); + assert_eq!( + shutdown_count_b.load(Ordering::SeqCst), + 0, + "B failed to initialize; shutdown should not run for it", + ); + + // Manager must report not-initialized after the failure. + assert!(!mgr.is_initialized()); } // -- Factory-based tests -- @@ -1703,11 +3447,14 @@ mod tests { fn create( &self, config: &PluginConfig, - ) -> Result { - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); - let handler: Arc = Arc::new( - TypedHandlerAdapter::::new(Arc::clone(&plugin)), - ); + ) -> Result> { + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new( + Arc::clone(&plugin), + )); Ok(crate::factory::PluginInstance { plugin, handlers: vec![("test_hook", handler)], @@ -1722,11 +3469,14 @@ mod tests { fn create( &self, config: &PluginConfig, - ) -> Result { - let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); - let handler: Arc = Arc::new( - TypedHandlerAdapter::::new(Arc::clone(&plugin)), - ); + ) -> Result> { + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new( + Arc::clone(&plugin), + )); Ok(crate::factory::PluginInstance { plugin, handlers: vec![("test_hook", handler)], @@ -1752,7 +3502,7 @@ plugin_settings: let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); assert_eq!(mgr.plugin_count(), 1); @@ -1774,7 +3524,7 @@ plugins: let mut factories = PluginFactoryRegistry::new(); factories.register("test/deny", Box::new(DenyPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); let payload: Box = Box::new(TestPayload { @@ -1803,7 +3553,11 @@ plugins: let result = PluginManager::from_config(cpex_config, &factories); match result { - Err(e) => assert!(e.to_string().contains("no factory registered"), "got: {}", e), + Err(e) => assert!( + e.to_string().contains("no factory registered"), + "got: {}", + e + ), Ok(_) => panic!("expected error for unknown kind"), } } @@ -1821,36 +3575,192 @@ plugins: kind: test/allow hooks: [test_hook] mode: sequential - priority: 10 + priority: 10 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + factories.register("test/deny", Box::new(DenyPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.plugin_count(), 2); + + // Deny plugin has higher priority (5 < 10), so it fires first + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + // context_table = None (first invocation) + + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(!result.continue_processing); // gate denied before fallback could allow + } + + // -- Routing cache tests -- + + #[tokio::test] + async fn test_routing_cache_populated_on_first_invoke() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential + priority: 10 +routes: + - tool: get_compensation +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + assert_eq!(mgr.routing_cache_size(), 0); + + // First invoke — populates cache + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let ext = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + // context_table = None (first invocation) + mgr.invoke_by_name("test_hook", payload, ext, None).await; + + assert_eq!(mgr.routing_cache_size(), 1); + + // Second invoke — cache hit, still size 1 + let payload2: Box = Box::new(TestPayload { + value: "test2".into(), + }); + let ext2 = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", payload2, ext2, None).await; + + assert_eq!(mgr.routing_cache_size(), 1); // cache hit — no new entry + } + + #[tokio::test] + async fn test_routing_cache_different_entities_separate() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation + - tool: send_email +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + let mut factories = PluginFactoryRegistry::new(); + factories.register("test/allow", Box::new(AllowPluginFactory)); + + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + mgr.initialize().await.unwrap(); + + // context_table = None (first invocation) + + // Invoke for get_compensation + let p1: Box = Box::new(TestPayload { value: "t".into() }); + let e1 = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p1, e1, None).await; + + // Invoke for send_email + let p2: Box = Box::new(TestPayload { value: "t".into() }); + let e2 = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("send_email".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", p2, e2, None).await; + + assert_eq!(mgr.routing_cache_size(), 2); + } + + #[tokio::test] + async fn test_routing_cache_cleared() { + let yaml = r#" +plugin_settings: + routing_enabled: true +global: + policies: + all: + plugins: [allow_plugin] +plugins: + - name: allow_plugin + kind: test/allow + hooks: [test_hook] + mode: sequential +routes: + - tool: get_compensation "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - factories.register("test/deny", Box::new(DenyPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); - assert_eq!(mgr.plugin_count(), 2); - - // Deny plugin has higher priority (5 < 10), so it fires first - let payload: Box = Box::new(TestPayload { - value: "test".into(), - }); // context_table = None (first invocation) + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let ext = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + ..Default::default() + })), + ..Default::default() + }; + mgr.invoke_by_name("test_hook", payload, ext, None).await; + assert_eq!(mgr.routing_cache_size(), 1); - let (result, _) = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), None) - .await; - - assert!(!result.continue_processing); // gate denied before fallback could allow + mgr.clear_routing_cache(); + assert_eq!(mgr.routing_cache_size(), 0); } - // -- Routing cache tests -- - #[tokio::test] - async fn test_routing_cache_populated_on_first_invoke() { + async fn test_unregister_invalidates_routing_cache() { let yaml = r#" plugin_settings: routing_enabled: true @@ -1863,7 +3773,6 @@ plugins: kind: test/allow hooks: [test_hook] mode: sequential - priority: 10 routes: - tool: get_compensation "#; @@ -1871,13 +3780,10 @@ routes: let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); - assert_eq!(mgr.routing_cache_size(), 0); - - // First invoke — populates cache - let payload: Box = Box::new(TestPayload { value: "test".into() }); + let payload: Box = Box::new(TestPayload { value: "t".into() }); let ext = Extensions { meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { entity_type: Some("tool".into()), @@ -1886,31 +3792,52 @@ routes: })), ..Default::default() }; - // context_table = None (first invocation) mgr.invoke_by_name("test_hook", payload, ext, None).await; - assert_eq!(mgr.routing_cache_size(), 1); - // Second invoke — cache hit, still size 1 - let payload2: Box = Box::new(TestPayload { value: "test2".into() }); - let ext2 = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", payload2, ext2, None).await; + // Unregister should invalidate the cache so removed plugins + // don't continue firing from stale cached entries. + mgr.unregister("allow_plugin"); + assert_eq!(mgr.routing_cache_size(), 0); + } - assert_eq!(mgr.routing_cache_size(), 1); // cache hit — no new entry + #[test] + fn test_routing_cache_recovers_from_poisoned_lock() { + // A panic while holding the cache lock poisons it. Before the fix, + // every subsequent read()/write() would unwrap a PoisonError and + // panic, permanently breaking dispatch. With unwrap_or_else + + // into_inner, the cache stays usable. + // + // Note: this test intentionally panics inside catch_unwind, which + // prints "thread 'manager::tests::...' panicked at..." to test + // output even though the panic is caught. That's expected. + use std::panic::AssertUnwindSafe; + + let mgr = PluginManager::default(); + + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + let _guard = mgr.route_cache.write().unwrap(); + panic!("simulated panic while holding cache lock"); + })); + assert!(result.is_err(), "expected the panic to be caught"); + assert!( + mgr.route_cache.is_poisoned(), + "lock should be poisoned after the panic", + ); + + // All four lock sites must now succeed despite the poison flag. + assert_eq!(mgr.routing_cache_size(), 0); + mgr.clear_routing_cache(); + assert_eq!(mgr.routing_cache_size(), 0); } #[tokio::test] - async fn test_routing_cache_different_entities_separate() { + async fn test_routing_cache_rejects_inserts_at_capacity() { + // Cap of 2 — verifies bound holds AND uncached requests still resolve correctly. let yaml = r#" plugin_settings: routing_enabled: true + route_cache_max_entries: 2 global: policies: all: @@ -1921,47 +3848,68 @@ plugins: hooks: [test_hook] mode: sequential routes: - - tool: get_compensation - - tool: send_email + - tool: a + - tool: b + - tool: c "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); - // context_table = None (first invocation) - - // Invoke for get_compensation - let p1: Box = Box::new(TestPayload { value: "t".into() }); - let e1 = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("get_compensation".into()), + let invoke_for = |entity: &'static str| -> (Box, Extensions) { + let p: Box = Box::new(TestPayload { + value: entity.into(), + }); + let e = Extensions { + meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(entity.into()), + ..Default::default() + })), ..Default::default() - })), - ..Default::default() + }; + (p, e) }; - mgr.invoke_by_name("test_hook", p1, e1, None).await; - // Invoke for send_email - let p2: Box = Box::new(TestPayload { value: "t".into() }); - let e2 = Extensions { - meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { - entity_type: Some("tool".into()), - entity_name: Some("send_email".into()), - ..Default::default() - })), - ..Default::default() - }; - mgr.invoke_by_name("test_hook", p2, e2, None).await; + // Fill to cap (2 distinct entities). + let (p1, e1) = invoke_for("a"); + let (r1, _) = mgr.invoke_by_name("test_hook", p1, e1, None).await; + assert!(r1.continue_processing); + assert_eq!(mgr.routing_cache_size(), 1); + + let (p2, e2) = invoke_for("b"); + let (r2, _) = mgr.invoke_by_name("test_hook", p2, e2, None).await; + assert!(r2.continue_processing); + assert_eq!(mgr.routing_cache_size(), 2); + + // Third entity — cache is full, insert is rejected. + // Pipeline must still run correctly (slow path resolves the route). + let (p3, e3) = invoke_for("c"); + let (r3, _) = mgr.invoke_by_name("test_hook", p3, e3, None).await; + assert!( + r3.continue_processing, + "slow path must still resolve when cache is full" + ); + assert_eq!(mgr.routing_cache_size(), 2, "cache must not exceed cap"); + // Repeated request for the same uncached entity also works. + let (p4, e4) = invoke_for("c"); + let (r4, _) = mgr.invoke_by_name("test_hook", p4, e4, None).await; + assert!(r4.continue_processing); assert_eq!(mgr.routing_cache_size(), 2); + + // Clearing the cache lets new entries memoize again. + mgr.clear_routing_cache(); + let (p5, e5) = invoke_for("c"); + mgr.invoke_by_name("test_hook", p5, e5, None).await; + assert_eq!(mgr.routing_cache_size(), 1); } #[tokio::test] - async fn test_routing_cache_cleared() { + async fn test_register_handler_invalidates_routing_cache() { let yaml = r#" plugin_settings: routing_enabled: true @@ -1981,10 +3929,9 @@ routes: let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); - // context_table = None (first invocation) let payload: Box = Box::new(TestPayload { value: "t".into() }); let ext = Extensions { meta: Some(std::sync::Arc::new(crate::hooks::payload::MetaExtension { @@ -1997,7 +3944,14 @@ routes: mgr.invoke_by_name("test_hook", payload, ext, None).await; assert_eq!(mgr.routing_cache_size(), 1); - mgr.clear_routing_cache(); + // Registering a new handler must invalidate the cache so the + // new plugin is visible to subsequent route resolutions. + let extra_cfg = make_config("late_plugin", 20, PluginMode::Sequential); + let extra = Arc::new(AllowPlugin { + cfg: extra_cfg.clone(), + }); + mgr.register_handler::(extra, extra_cfg) + .unwrap(); assert_eq!(mgr.routing_cache_size(), 0); } @@ -2022,7 +3976,7 @@ routes: let mut factories = PluginFactoryRegistry::new(); factories.register("test/allow", Box::new(AllowPluginFactory)); - let mut mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); + let mgr = PluginManager::from_config(cpex_config, &factories).unwrap(); mgr.initialize().await.unwrap(); // context_table = None (first invocation) @@ -2080,7 +4034,7 @@ routes: let cpex_config = crate::config::parse_config(yaml).unwrap(); // Use register_factory + load_config so manager owns factories - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.load_config(cpex_config).unwrap(); mgr.initialize().await.unwrap(); @@ -2097,9 +4051,7 @@ routes: }; // context_table = None (first invocation) - let (result, _) = mgr - .invoke_by_name("test_hook", payload, ext, None) - .await; + let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; // Plugin executed (allow plugin returns allowed) assert!(result.continue_processing); @@ -2107,6 +4059,187 @@ routes: assert_eq!(mgr.routing_cache_size(), 1); } + /// Override instances must have `initialize()` called so plugins that + /// open DB connections / file handles / network clients on init don't + /// run with default state. Uses a tracking factory whose plugin + /// increments a counter inside its `initialize()`. + #[tokio::test] + async fn test_route_override_initializes_new_instance() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static INIT_COUNT: AtomicUsize = AtomicUsize::new(0); + INIT_COUNT.store(0, Ordering::SeqCst); + + struct InitTrackingPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for InitTrackingPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), Box> { + INIT_COUNT.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } + } + + impl HookHandler for InitTrackingPlugin { + fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } + } + + struct InitTrackingFactory; + impl crate::factory::PluginFactory for InitTrackingFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + let plugin = Arc::new(InitTrackingPlugin { + cfg: config.clone(), + }); + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new( + Arc::clone(&plugin), + )); + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) + } + } + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: tracker + kind: test/init_tracking + hooks: [test_hook] + mode: sequential + priority: 10 + config: + max_requests: 100 +routes: + - tool: get_compensation + plugins: + - tracker: + config: + max_requests: 10 +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mgr = PluginManager::default(); + mgr.register_factory("test/init_tracking", Box::new(InitTrackingFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // Base plugin was initialized exactly once during mgr.initialize(). + assert_eq!(INIT_COUNT.load(Ordering::SeqCst), 1); + + // Invoke with route override — creates a new instance via factory. + // That new instance must also be initialized. + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let (result, _) = mgr + .invoke_by_name( + "test_hook", + payload, + make_meta("tool", "get_compensation", None, &[]), + None, + ) + .await; + assert!(result.continue_processing); + + assert_eq!( + INIT_COUNT.load(Ordering::SeqCst), + 2, + "override instance must have initialize() called", + ); + } + + /// Override and base must have INDEPENDENT circuit breakers. A failure + /// on an override-only route (e.g., bad credentials in the merged + /// config) must not silently disable the plugin for every other route + /// using the base config — config is part of the failure surface, and + /// per-route blast radius is the point of having overrides. + #[tokio::test] + async fn test_route_override_circuit_breaker_isolated_from_base() { + struct ErrorOnInvokeFactory; + impl crate::factory::PluginFactory for ErrorOnInvokeFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + let handler: Arc = Arc::new(ErrorHandler); + Ok(crate::factory::PluginInstance { + plugin, + handlers: vec![("test_hook", handler)], + }) + } + } + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: flaky + kind: test/error_on_invoke + hooks: [test_hook] + mode: sequential + priority: 10 + on_error: disable +routes: + - tool: get_compensation + plugins: + - flaky: + config: + something: changed +"#; + let cpex_config = crate::config::parse_config(yaml).unwrap(); + + let mgr = PluginManager::default(); + mgr.register_factory("test/error_on_invoke", Box::new(ErrorOnInvokeFactory)); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + assert!( + !mgr.get_plugin("flaky").unwrap().is_disabled(), + "should start enabled" + ); + + // Invoke a route that uses the override. The override's handler + // errors with `on_error: Disable`, so the executor calls disable() + // on the *override's* plugin_ref. Independent circuit breakers + // mean the base must stay enabled. + let payload: Box = Box::new(TestPayload { value: "t".into() }); + let _ = mgr + .invoke_by_name( + "test_hook", + payload, + make_meta("tool", "get_compensation", None, &[]), + None, + ) + .await; + + assert!( + !mgr.get_plugin("flaky").unwrap().is_disabled(), + "base must NOT be disabled when an override trips its own circuit breaker", + ); + } + #[tokio::test] async fn test_register_factory_then_load_config() { let yaml = r#" @@ -2122,7 +4255,7 @@ plugin_settings: "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.load_config(cpex_config).unwrap(); mgr.initialize().await.unwrap(); @@ -2203,7 +4336,7 @@ routes: - rate_limiter "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); mgr.load_config(cpex_config).unwrap(); @@ -2215,7 +4348,12 @@ routes: // apl_policy denies → overall denied let p1: Box = Box::new(TestPayload { value: "t".into() }); let (r1, _) = mgr - .invoke_by_name("test_hook", p1, make_meta("tool", "get_compensation", None, &[]), None) + .invoke_by_name( + "test_hook", + p1, + make_meta("tool", "get_compensation", None, &[]), + None, + ) .await; assert!(!r1.continue_processing); // apl_policy (deny) fires due to pii tag @@ -2223,7 +4361,12 @@ routes: // both allow → overall allowed let p2: Box = Box::new(TestPayload { value: "t".into() }); let (r2, _) = mgr - .invoke_by_name("test_hook", p2, make_meta("tool", "send_email", None, &[]), None) + .invoke_by_name( + "test_hook", + p2, + make_meta("tool", "send_email", None, &[]), + None, + ) .await; assert!(r2.continue_processing); // no deny plugin fires } @@ -2245,7 +4388,7 @@ plugins: priority: 20 "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); mgr.load_config(cpex_config).unwrap(); @@ -2256,7 +4399,12 @@ plugins: // Even with meta, routing disabled → all plugins fire → denier wins let p: Box = Box::new(TestPayload { value: "t".into() }); let (result, _) = mgr - .invoke_by_name("test_hook", p, make_meta("tool", "anything", None, &[]), None) + .invoke_by_name( + "test_hook", + p, + make_meta("tool", "anything", None, &[]), + None, + ) .await; assert!(!result.continue_processing); // denier fires (all plugins active) } @@ -2286,7 +4434,7 @@ routes: - denier "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); mgr.load_config(cpex_config).unwrap(); @@ -2299,10 +4447,17 @@ routes: let (result, _) = mgr .invoke_by_name("test_hook", p, Extensions::default(), None) .await; - // denier has default priority 100, allower has default 100 — order depends on registration - // but at least both fire (not filtered by routing) - // We can't assert allow/deny specifically since both run — just check it executed - assert!(result.continue_processing || !result.continue_processing); // both plugins fired + // No meta → no route resolution → both plugins fire. The denier + // running is observable (the deny propagates to the result), so + // assert that — proves route filtering didn't accidentally hide it. + assert!( + !result.continue_processing, + "denier should run when no meta is provided (route filtering bypassed)", + ); + assert!( + result.violation.is_some(), + "deny should produce a violation" + ); } #[tokio::test] @@ -2339,7 +4494,7 @@ routes: - fallback_plugin "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); mgr.load_config(cpex_config).unwrap(); @@ -2350,14 +4505,24 @@ routes: // get_compensation matches exact route → specific_plugin (deny) let p1: Box = Box::new(TestPayload { value: "t".into() }); let (r1, _) = mgr - .invoke_by_name("test_hook", p1, make_meta("tool", "get_compensation", None, &[]), None) + .invoke_by_name( + "test_hook", + p1, + make_meta("tool", "get_compensation", None, &[]), + None, + ) .await; assert!(!r1.continue_processing); // specific_plugin denies // unknown_tool matches wildcard → fallback_plugin (allow) let p2: Box = Box::new(TestPayload { value: "t".into() }); let (r2, _) = mgr - .invoke_by_name("test_hook", p2, make_meta("tool", "unknown_tool", None, &[]), None) + .invoke_by_name( + "test_hook", + p2, + make_meta("tool", "unknown_tool", None, &[]), + None, + ) .await; assert!(r2.continue_processing); // fallback_plugin allows } @@ -2388,7 +4553,7 @@ routes: - tool: get_compensation "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); mgr.load_config(cpex_config).unwrap(); @@ -2399,7 +4564,12 @@ routes: // Without urgent tag → only identity fires → allowed let p1: Box = Box::new(TestPayload { value: "t".into() }); let (r1, _) = mgr - .invoke_by_name("test_hook", p1, make_meta("tool", "get_compensation", None, &[]), None) + .invoke_by_name( + "test_hook", + p1, + make_meta("tool", "get_compensation", None, &[]), + None, + ) .await; assert!(r1.continue_processing); @@ -2409,7 +4579,12 @@ routes: // With urgent tag from host → denier also fires → denied let p2: Box = Box::new(TestPayload { value: "t".into() }); let (r2, _) = mgr - .invoke_by_name("test_hook", p2, make_meta("tool", "get_compensation", None, &["urgent"]), None) + .invoke_by_name( + "test_hook", + p2, + make_meta("tool", "get_compensation", None, &["urgent"]), + None, + ) .await; assert!(!r2.continue_processing); } @@ -2443,7 +4618,7 @@ routes: - tool: send_email "#; let cpex_config = crate::config::parse_config(yaml).unwrap(); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); mgr.register_factory("test/allow", Box::new(AllowPluginFactory)); mgr.register_factory("test/deny", Box::new(DenyPluginFactory)); mgr.load_config(cpex_config).unwrap(); @@ -2484,7 +4659,7 @@ routes: _payload: &dyn PluginPayload, extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { let mut ext = extensions.cow_copy(); if let Some(ref mut sec) = ext.security { sec.add_label("PLUGIN_ADDED"); @@ -2493,7 +4668,9 @@ routes: result.modified_extensions = Some(ext); Ok(crate::executor::erase_result(result)) } - fn hook_type_name(&self) -> &'static str { "test_hook" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } /// Handler that tampers with an immutable extension slot. @@ -2506,30 +4683,33 @@ routes: _payload: &dyn PluginPayload, extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { let mut ext = extensions.cow_copy(); // Tamper: replace the immutable request extension - ext.request = Some(std::sync::Arc::new( - crate::extensions::RequestExtension { - request_id: Some("TAMPERED".into()), - ..Default::default() - } - )); + ext.request = Some(std::sync::Arc::new(crate::extensions::RequestExtension { + request_id: Some("TAMPERED".into()), + ..Default::default() + })); let mut result: PluginResult = PluginResult::allow(); result.modified_extensions = Some(ext); Ok(crate::executor::erase_result(result)) } - fn hook_type_name(&self) -> &'static str { "test_hook" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } #[tokio::test] async fn test_executor_accepts_valid_label_addition() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let mut config = make_config("label-adder", 10, PluginMode::Sequential); config.capabilities = ["append_labels".to_string(), "read_labels".to_string()].into(); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); let handler: Arc = Arc::new(LabelAdderHandler); - mgr.register_raw::(plugin, config, handler).unwrap(); + mgr.register_raw::(plugin, config, handler) + .unwrap(); mgr.initialize().await.unwrap(); // Build extensions with a security label @@ -2541,7 +4721,9 @@ routes: ..Default::default() }; - let payload: Box = Box::new(TestPayload { value: "test".into() }); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; assert!(result.continue_processing); @@ -2554,11 +4736,14 @@ routes: #[tokio::test] async fn test_executor_rejects_immutable_tampering() { - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); let config = make_config("tamperer", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); let handler: Arc = Arc::new(ImmutableTampererHandler); - mgr.register_raw::(plugin, config, handler).unwrap(); + mgr.register_raw::(plugin, config, handler) + .unwrap(); mgr.initialize().await.unwrap(); // Build extensions with a request extension @@ -2570,7 +4755,9 @@ routes: ..Default::default() }; - let payload: Box = Box::new(TestPayload { value: "test".into() }); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; assert!(result.continue_processing); @@ -2600,27 +4787,33 @@ routes: _payload: &dyn PluginPayload, extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { // Check if security is visible if extensions.security.is_some() { - self.saw_security.store(true, std::sync::atomic::Ordering::SeqCst); + self.saw_security + .store(true, std::sync::atomic::Ordering::SeqCst); } let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) } - fn hook_type_name(&self) -> &'static str { "test_hook" } + fn hook_type_name(&self) -> &'static str { + "test_hook" + } } let saw_security = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let mut mgr = PluginManager::default(); + let mgr = PluginManager::default(); // No security capabilities declared let config = make_config("no-sec-caps", 10, PluginMode::Sequential); - let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); let handler: Arc = Arc::new(SecurityCheckerHandler { saw_security: saw_security.clone(), }); - mgr.register_raw::(plugin, config, handler).unwrap(); + mgr.register_raw::(plugin, config, handler) + .unwrap(); mgr.initialize().await.unwrap(); // Build extensions WITH security data @@ -2636,7 +4829,9 @@ routes: ..Default::default() }; - let payload: Box = Box::new(TestPayload { value: "test".into() }); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); let (result, _) = mgr.invoke_by_name("test_hook", payload, ext, None).await; assert!(result.continue_processing); diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs index 95b05f63..dd743a24 100644 --- a/crates/cpex-core/src/plugin.rs +++ b/crates/cpex-core/src/plugin.rs @@ -51,8 +51,8 @@ use crate::error::PluginError; /// ```rust,ignore /// impl Plugin for MyPlugin { /// fn config(&self) -> &PluginConfig { &self.config } -/// async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } -/// async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } +/// async fn initialize(&self) -> Result<(), Box> { Ok(()) } +/// async fn shutdown(&self) -> Result<(), Box> { Ok(()) } /// } /// /// impl CmfHookHandler for MyPlugin { @@ -90,7 +90,7 @@ pub trait Plugin: Send + Sync { /// Called before any hook invocations. Use this to establish /// connections, load resources, or validate configuration. /// Default implementation does nothing. - async fn initialize(&self) -> Result<(), PluginError> { + async fn initialize(&self) -> Result<(), Box> { Ok(()) } @@ -99,7 +99,7 @@ pub trait Plugin: Send + Sync { /// Called once during teardown. Use this to flush buffers, close /// connections, or release resources. /// Default implementation does nothing. - async fn shutdown(&self) -> Result<(), PluginError> { + async fn shutdown(&self) -> Result<(), Box> { Ok(()) } } @@ -204,6 +204,86 @@ pub struct PluginConfig { pub config: Option, } +impl PluginConfig { + /// Whether this plugin's `conditions` allow it to fire for the given + /// request `Extensions`. Used in legacy mode (`routing_enabled: false`) + /// to filter which plugins run per request — mirrors the Python + /// implementation's per-plugin condition filtering. + /// + /// Semantics: + /// - Empty `conditions` Vec → fire always (no restriction). + /// - Non-empty → fire if ANY condition matches (OR across the list, + /// AND within each individual condition). + /// + /// Field-source mapping (see project memory `project_conditions_field_mapping`): + /// - `server_ids` ← `extensions.mcp.{tool|resource|prompt}.server_id` + /// - `tenant_ids` ← `extensions.security.subject.claims["tenant"]` + /// - `tools|prompts|resources` ← `extensions.meta.entity_name` (when matching `entity_type`) + /// - `agents` ← `extensions.agent.agent_id` + /// - `user_patterns` ← `extensions.security.subject.id` (glob match) + /// - `content_types` ← `extensions.mcp.resource.mime_type` + pub fn passes_conditions(&self, extensions: &crate::hooks::payload::Extensions) -> bool { + if self.conditions.is_empty() { + return true; + } + + // Source values once from the extensions tree. + let server_id = extensions.mcp.as_ref().and_then(|m| { + m.tool + .as_ref() + .and_then(|t| t.server_id.as_deref()) + .or_else(|| m.resource.as_ref().and_then(|r| r.server_id.as_deref())) + .or_else(|| m.prompt.as_ref().and_then(|p| p.server_id.as_deref())) + }); + let tenant_id = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|sub| sub.claims.get("tenant")) + .map(|s| s.as_str()); + let entity_name = extensions + .meta + .as_ref() + .and_then(|m| m.entity_name.as_deref()); + let entity_type = extensions + .meta + .as_ref() + .and_then(|m| m.entity_type.as_deref()); + let (tool, prompt, resource) = match entity_type { + Some("tool") => (entity_name, None, None), + Some("prompt") => (None, entity_name, None), + Some("resource") => (None, None, entity_name), + _ => (None, None, None), + }; + let agent = extensions + .agent + .as_ref() + .and_then(|a| a.agent_id.as_deref()); + let user = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|sub| sub.id.as_deref()); + let content_type = extensions + .mcp + .as_ref() + .and_then(|m| m.resource.as_ref()) + .and_then(|r| r.mime_type.as_deref()); + + let ctx = MatchContext { + server_id, + tenant_id, + tool, + prompt, + resource, + agent, + user, + content_type, + }; + self.conditions.iter().any(|c| c.matches(&ctx)) + } +} + fn default_priority() -> i32 { 100 } @@ -274,22 +354,47 @@ pub struct PluginCondition { pub content_types: Option>, } +/// Bundle of optional context values used to evaluate a `PluginCondition`. +/// +/// Each field corresponds to one of the condition's gates. `None` means +/// "no value sourced from the extensions tree"; the condition then +/// rejects when the corresponding `Some(set)` is set on the condition +/// (i.e., the gate was specified but couldn't be evaluated). +/// +/// Replaces an 8-arg `matches(...)` call where every arg was +/// `Option<&str>` and could be misordered silently. +#[derive(Debug, Default, Clone, Copy)] +pub struct MatchContext<'a> { + pub server_id: Option<&'a str>, + pub tenant_id: Option<&'a str>, + pub tool: Option<&'a str>, + pub prompt: Option<&'a str>, + pub resource: Option<&'a str>, + pub agent: Option<&'a str>, + pub user: Option<&'a str>, + pub content_type: Option<&'a str>, +} + impl PluginCondition { /// Whether this condition matches the given context. /// /// A field that is `None` is treated as "any" (no restriction). - /// A field that is `Some(set)` matches if the given value is in the set. - /// All specified fields must match (AND semantics). - pub fn matches( - &self, - server_id: Option<&str>, - tenant_id: Option<&str>, - tool: Option<&str>, - prompt: Option<&str>, - resource: Option<&str>, - agent: Option<&str>, - ) -> bool { - let check = |field: &Option>, value: Option<&str>| -> bool { + /// A `Some(set)` field matches if the given value is in the set + /// (exact match for ID-shaped fields; glob match via `wildmatch` + /// for `user_patterns`). + /// All specified fields must match — AND semantics within one condition. + pub fn matches(&self, ctx: &MatchContext<'_>) -> bool { + let MatchContext { + server_id, + tenant_id, + tool, + prompt, + resource, + agent, + user, + content_type, + } = *ctx; + let check_set = |field: &Option>, value: Option<&str>| -> bool { match field { None => true, // not specified — matches anything Some(set) => match value { @@ -299,12 +404,38 @@ impl PluginCondition { } }; - check(&self.server_ids, server_id) - && check(&self.tenant_ids, tenant_id) - && check(&self.tools, tool) - && check(&self.prompts, prompt) - && check(&self.resources, resource) - && check(&self.agents, agent) + // user_patterns: list of globs. Match if any pattern matches the user. + let check_patterns = |field: &Option>, value: Option<&str>| -> bool { + match field { + None => true, + Some(patterns) => match value { + Some(v) => patterns + .iter() + .any(|p| wildmatch::WildMatch::new(p).matches(v)), + None => false, + }, + } + }; + + // content_types: list of exact strings. + let check_list = |field: &Option>, value: Option<&str>| -> bool { + match field { + None => true, + Some(list) => match value { + Some(v) => list.iter().any(|s| s == v), + None => false, + }, + } + }; + + check_set(&self.server_ids, server_id) + && check_set(&self.tenant_ids, tenant_id) + && check_set(&self.tools, tool) + && check_set(&self.prompts, prompt) + && check_set(&self.resources, resource) + && check_set(&self.agents, agent) + && check_patterns(&self.user_patterns, user) + && check_list(&self.content_types, content_type) } } @@ -335,6 +466,7 @@ impl PluginCondition { /// | FireAndForget | No | No | Background | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum PluginMode { /// Policy enforcement + transformation. Serial, chained. Can block and modify. #[default] @@ -397,6 +529,7 @@ impl fmt::Display for PluginMode { /// skipped, or cause the plugin to be auto-disabled. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum OnError { /// Pipeline halts and error propagates. Fail-safe enforcement. #[default] diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index fd3ff3c2..0b4990c1 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -31,7 +31,9 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; +use uuid::Uuid; use crate::context::PluginContext; use crate::hooks::payload::{Extensions, PluginPayload}; @@ -68,7 +70,10 @@ pub struct PluginRef { trusted_config: PluginConfig, /// Unique identifier assigned by the registry. - id: String, + /// Stored as `Uuid` (16 bytes, `Copy`) rather than a 36-char `String` + /// to avoid heap allocation per registered plugin and to give + /// downstream `HashMap` keys fixed-size hashing. + id: Uuid, /// Runtime circuit breaker — set to true when `on_error: Disable` /// triggers. Once set, `mode()` returns `Disabled` and the plugin @@ -84,11 +89,10 @@ impl PluginRef { /// NOT from `plugin.config()`. The plugin may hold its own copy /// for reading during execute(), but the manager never consults it. pub fn new(plugin: Arc, trusted_config: PluginConfig) -> Self { - let id = uuid::Uuid::new_v4().to_string(); Self { plugin, trusted_config, - id, + id: Uuid::new_v4(), disabled: Arc::new(AtomicBool::new(false)), } } @@ -104,8 +108,9 @@ impl PluginRef { } /// Unique identifier assigned at registration. - pub fn id(&self) -> &str { - &self.id + /// Returned by value — `Uuid` is `Copy` (16 bytes). + pub fn id(&self) -> Uuid { + self.id } /// Convenience: plugin name from the trusted config. @@ -115,8 +120,12 @@ impl PluginRef { /// Effective mode — returns `Disabled` if the runtime circuit breaker /// has tripped, otherwise returns the configured mode. + /// + /// `Acquire` on the load pairs with `Release` on the disable() store + /// so weak-memory-ordering hardware (ARM64) propagates the disable + /// promptly across threads. pub fn mode(&self) -> PluginMode { - if self.disabled.load(Ordering::Relaxed) { + if self.disabled.load(Ordering::Acquire) { PluginMode::Disabled } else { self.trusted_config.mode @@ -127,14 +136,20 @@ impl PluginRef { /// /// Called by the executor when a plugin errors with `on_error: Disable`. /// All clones of this PluginRef (in HookEntry, etc.) share the same - /// `AtomicBool`, so the disable is instantly visible across the system. + /// `AtomicBool`, so the disable is visible across the system. + /// + /// `Release` ordering establishes a happens-before with `Acquire` + /// loads in `is_disabled()` and `mode()` — required for correctness + /// on weak-memory hardware (ARM64) where `Relaxed` allows the new + /// value to remain unobserved by other threads for an unbounded window. pub fn disable(&self) { - self.disabled.store(true, Ordering::Relaxed); + self.disabled.store(true, Ordering::Release); } /// Whether this plugin has been runtime-disabled. + /// `Acquire` pairs with the `Release` in `disable()` (see `mode()`). pub fn is_disabled(&self) -> bool { - self.disabled.load(Ordering::Relaxed) + self.disabled.load(Ordering::Acquire) } /// Convenience: plugin priority from the trusted config. @@ -175,7 +190,7 @@ pub trait AnyHookHandler: Send + Sync { payload: &dyn PluginPayload, extensions: &Extensions, ctx: &mut PluginContext, - ) -> Result, crate::error::PluginError>; + ) -> Result, Box>; /// The hook type name this handler was registered for. fn hook_type_name(&self) -> &'static str; @@ -189,10 +204,15 @@ pub trait AnyHookHandler: Send + Sync { /// /// The executor uses `plugin_ref` for scheduling decisions (mode, /// priority, capabilities) and `handler` for actual dispatch. +/// +/// `plugin_ref` is `Arc` so cloning a `HookEntry` is two +/// reference-count bumps rather than a deep clone of the embedded +/// `PluginConfig`. `group_by_mode` (called once per invoke) clones N +/// entries — keeping that cheap matters at high request rates. #[derive(Clone)] pub struct HookEntry { /// The plugin wrapper with authoritative config. - pub plugin_ref: PluginRef, + pub plugin_ref: Arc, /// The type-erased handler for this specific hook. pub handler: Arc, @@ -216,9 +236,19 @@ pub struct HookEntry { /// - `register_for_names::()` — typed registration for multiple /// hook names (the CMF pattern where one handler covers /// `cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). +/// +/// `Clone` is cheap-ish: the HashMaps duplicate, but their values are all +/// `Arc`-counted (`Arc`, `Arc`), so the +/// inner data is shared. Used by `PluginManager`'s `ArcSwap` snapshot +/// pattern, where every mutating method clones the registry, mutates the +/// clone, and atomically swaps in a new snapshot. +#[derive(Clone)] pub struct PluginRegistry { - /// Plugins keyed by name (for lookup and lifecycle). - plugins: HashMap, + /// Plugins keyed by name (for lookup and lifecycle). Wrapped in `Arc` + /// so the same instance is shared with every `HookEntry` in + /// `hook_index` — registering a plugin allocates one `PluginRef`, + /// not one per hook. + plugins: HashMap>, /// Hook name → list of HookEntries, sorted by priority. hook_index: HashMap>, @@ -294,7 +324,6 @@ impl PluginRegistry { self.register_for_names_inner(plugin, config, handler, names) } - /// Register a plugin with multiple handlers, each for a specific hook. /// /// Used when a plugin implements multiple hook types with different @@ -315,12 +344,12 @@ impl PluginRegistry { return Err(format!("plugin '{}' is already registered", name)); } - let plugin_ref = PluginRef::new(plugin, config); + let plugin_ref = Arc::new(PluginRef::new(plugin, config)); for (hook_name, handler) in &handlers { let hook_type = HookType::new(*hook_name); let entry = HookEntry { - plugin_ref: plugin_ref.clone(), + plugin_ref: Arc::clone(&plugin_ref), handler: Arc::clone(handler), }; self.hook_index.entry(hook_type).or_default().push(entry); @@ -352,13 +381,13 @@ impl PluginRegistry { return Err(format!("plugin '{}' is already registered", name)); } - let plugin_ref = PluginRef::new(plugin, config); + let plugin_ref = Arc::new(PluginRef::new(plugin, config)); // Add to hook index for each specified hook name for hook_name in names { let hook_type = HookType::new(*hook_name); let entry = HookEntry { - plugin_ref: plugin_ref.clone(), + plugin_ref: Arc::clone(&plugin_ref), handler: Arc::clone(&handler), }; self.hook_index.entry(hook_type).or_default().push(entry); @@ -379,8 +408,8 @@ impl PluginRegistry { /// Unregister a plugin by name. /// /// Removes the PluginRef from the name index and all HookEntries - /// from the hook index. Returns the PluginRef if found. - pub fn unregister(&mut self, name: &str) -> Option { + /// from the hook index. Returns the (Arc-wrapped) PluginRef if found. + pub fn unregister(&mut self, name: &str) -> Option> { let plugin_ref = self.plugins.remove(name)?; // Remove from hook index @@ -394,9 +423,11 @@ impl PluginRegistry { Some(plugin_ref) } - /// Look up a PluginRef by name. - pub fn get(&self, name: &str) -> Option<&PluginRef> { - self.plugins.get(name) + /// Look up a PluginRef by name. Returns an `Arc` clone so callers + /// don't hold borrows on internal storage — works with snapshot-based + /// dispatch where the registry may sit behind a transient guard. + pub fn get(&self, name: &str) -> Option> { + self.plugins.get(name).map(Arc::clone) } /// Returns all HookEntries for a given hook name, sorted by priority. @@ -422,9 +453,11 @@ impl PluginRegistry { self.plugins.len() } - /// All registered plugin names. - pub fn plugin_names(&self) -> Vec<&str> { - self.plugins.keys().map(|s| s.as_str()).collect() + /// All registered plugin names. Returns owned `String`s so callers + /// don't hold borrows on internal storage — works with snapshot-based + /// dispatch where the registry may sit behind a transient guard. + pub fn plugin_names(&self) -> Vec { + self.plugins.keys().cloned().collect() } } @@ -444,15 +477,15 @@ impl Default for PluginRegistry { /// Returns a tuple of five vectors in execution order: /// (sequential, transform, audit, concurrent, fire_and_forget). /// Disabled plugins are excluded. -pub fn group_by_mode( - entries: &[HookEntry], -) -> ( +pub type GroupedHookEntries = ( Vec, Vec, Vec, Vec, Vec, -) { +); + +pub fn group_by_mode(entries: &[HookEntry]) -> GroupedHookEntries { let mut sequential = Vec::new(); let mut transform = Vec::new(); let mut audit = Vec::new(); @@ -484,6 +517,7 @@ mod tests { // -- Test payload and hook type -- #[derive(Debug, Clone)] + #[allow(dead_code)] // test fixture — typed shape is the point, not field reads struct TestPayload { value: String, } @@ -501,7 +535,7 @@ mod tests { _payload: &dyn PluginPayload, _extensions: &Extensions, _ctx: &mut PluginContext, - ) -> Result, PluginError> { + ) -> Result, Box> { let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) } @@ -546,10 +580,10 @@ mod tests { fn config(&self) -> &PluginConfig { &self.cfg } - async fn initialize(&self) -> Result<(), PluginError> { + async fn initialize(&self) -> Result<(), Box> { Ok(()) } - async fn shutdown(&self) -> Result<(), PluginError> { + async fn shutdown(&self) -> Result<(), Box> { Ok(()) } } @@ -588,7 +622,11 @@ mod tests { plugin, config, handler, - &["cmf.tool_pre_invoke", "cmf.tool_post_invoke", "cmf.llm_input"], + &[ + "cmf.tool_pre_invoke", + "cmf.tool_post_invoke", + "cmf.llm_input", + ], ) .unwrap(); @@ -609,8 +647,12 @@ mod tests { let h1: Arc = Arc::new(TestHandler); let h2: Arc = Arc::new(TestHandler); - assert!(reg.register_for_names_inner(p1, c1, h1, &["hook_a"]).is_ok()); - assert!(reg.register_for_names_inner(p2, c2, h2, &["hook_a"]).is_err()); + assert!(reg + .register_for_names_inner(p1, c1, h1, &["hook_a"]) + .is_ok()); + assert!(reg + .register_for_names_inner(p2, c2, h2, &["hook_a"]) + .is_err()); } #[test] @@ -623,8 +665,10 @@ mod tests { let h1: Arc = Arc::new(TestHandler); let h2: Arc = Arc::new(TestHandler); - reg.register_for_names_inner(p_low, c_low, h1, &["hook_a"]).unwrap(); - reg.register_for_names_inner(p_high, c_high, h2, &["hook_a"]).unwrap(); + reg.register_for_names_inner(p_low, c_low, h1, &["hook_a"]) + .unwrap(); + reg.register_for_names_inner(p_high, c_high, h2, &["hook_a"]) + .unwrap(); let entries = reg.entries_for_hook(&HookType::new("hook_a")); assert_eq!(entries[0].plugin_ref.name(), "high"); // priority 10 first @@ -678,7 +722,10 @@ mod tests { let ext = Extensions::default(); let mut ctx = PluginContext::new(); - let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &mut ctx).await.unwrap(); + let result = handler + .invoke(&payload as &dyn PluginPayload, &ext, &mut ctx) + .await + .unwrap(); let fields = crate::executor::extract_erased(result).unwrap(); assert!(fields.continue_processing); } diff --git a/crates/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml new file mode 100644 index 00000000..73d55683 --- /dev/null +++ b/crates/cpex-ffi/Cargo.toml @@ -0,0 +1,31 @@ +# Location: ./crates/cpex-ffi/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX FFI — C API for embedding the CPEX runtime in Go, Python, etc. +# Compiles to a shared library (cdylib) exporting extern "C" functions. +# Payloads cross the FFI boundary as MessagePack bytes. + +[package] +name = "cpex-ffi" +description = "CPEX C FFI — shared library for Go/Python/WASM host bindings." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +crate-type = ["lib", "cdylib", "staticlib"] + +[dependencies] +cpex-core = { path = "../cpex-core" } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +rmp-serde = { workspace = true } +serde_bytes = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +async-trait = { workspace = true } diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs new file mode 100644 index 00000000..f8bb615f --- /dev/null +++ b/crates/cpex-ffi/src/lib.rs @@ -0,0 +1,1313 @@ +// Location: ./crates/cpex-ffi/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX FFI — C API for embedding the CPEX runtime. +// +// Exports extern "C" functions that Go (via cgo), Python (via ctypes/cffi), +// and other languages can call. Payloads and extensions cross the boundary +// as MessagePack bytes. ContextTable and BackgroundTasks are opaque handles. +// +// Each PluginManager owns its own tokio runtime so async plugin execution +// works from synchronous cgo calls. + +use std::os::raw::{c_char, c_int}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::ptr; +use std::sync::OnceLock; +use std::time::Duration; + +use cpex_core::context::PluginContextTable; +use cpex_core::executor::BackgroundTasks; +use cpex_core::extensions::Extensions; +use cpex_core::hooks::payload::PluginPayload; +use cpex_core::manager::PluginManager; + +// --------------------------------------------------------------------------- +// FFI Result Codes +// --------------------------------------------------------------------------- +// +// FFI functions return c_int. 0 means success; negative codes classify +// failures so the Go (or other-language) caller can produce a typed +// error rather than a single opaque "invoke failed" string. The codes +// are stable wire ABI — additions go at the end with a fresh value; +// don't renumber. +// +// Mapped on the Go side in `go/cpex/manager.go::errorFromRC`. + +/// Operation succeeded. +pub const RC_OK: c_int = 0; +/// Manager handle is null or shut down. +pub const RC_INVALID_HANDLE: c_int = -1; +/// Caller-supplied input is malformed (bad UTF-8, null pointer where +/// data was required, oversized buffer, unknown payload type). +pub const RC_INVALID_INPUT: c_int = -2; +/// Parse / deserialize step failed (YAML config, MessagePack payload, +/// MessagePack extensions). +pub const RC_PARSE_ERROR: c_int = -3; +/// Pipeline / lifecycle step failed: `load_config` returned Err, +/// `initialize` returned Err, or a plugin signalled failure during +/// invoke without a wall-clock timeout or panic. +pub const RC_PIPELINE_ERROR: c_int = -4; +/// Result serialization (post-pipeline) failed — usually OOM on +/// `rmp_serde::to_vec_named` or unserializable JSON value. +pub const RC_SERIALIZE_ERROR: c_int = -5; +/// Wall-clock timeout exceeded inside `run_safely` — plugin likely +/// CPU-bound or blocking the OS thread without yielding. +pub const RC_TIMEOUT: c_int = -6; +/// Plugin panicked; caught by `catch_unwind` at the FFI boundary. +pub const RC_PANIC: c_int = -7; + +/// Outer wall-clock timeout for any FFI-driven async call. Per-plugin +/// `tokio::time::timeout` only catches cooperative-async timeouts; this +/// catches CPU-bound or thread-blocking plugins that never yield. Set +/// generously — bigger than any reasonable per-plugin timeout — so the +/// usual case never hits this bound. +const FFI_WALL_CLOCK_TIMEOUT: Duration = Duration::from_secs(60); + +// --------------------------------------------------------------------------- +// Shared Tokio Runtime +// --------------------------------------------------------------------------- +// +// One process-singleton runtime serves every manager rather than each +// `cpex_manager_new` building its own. With many managers (multi-tenant +// hosts that create one per request, dynamic plugin reload, etc.) the +// per-manager model exploded thread count: 100 managers × num_cpus +// workers each = hundreds of OS threads (and ~2MB stack apiece). +// +// Worker thread count precedence (highest first): +// 1. `cpex_configure_runtime(N)` — explicit FFI call, before first use. +// 2. `CPEX_FFI_WORKER_THREADS` env var — operator-friendly; read once +// on first use of `shared_runtime()`. +// 3. tokio default (`num_cpus`). +// +// Once the runtime is initialized it's fixed for the process lifetime. +static SHARED_RUNTIME: OnceLock = OnceLock::new(); + +/// Name of the env var operators set to bound worker threads without +/// recompiling host code or touching YAML. +const ENV_WORKER_THREADS: &str = "CPEX_FFI_WORKER_THREADS"; + +/// Parse `CPEX_FFI_WORKER_THREADS` if set. Returns `Some(n)` for valid +/// positive integers, `None` for unset / zero / negative / unparseable +/// values (in which case the runtime falls back to tokio's default). +/// Logs a warning on malformed values so operators see why their +/// setting was ignored. +/// +/// Extracted from `shared_runtime` so it's unit-testable without +/// touching the global `OnceLock`. +fn worker_threads_from_env() -> Option { + let raw = std::env::var(ENV_WORKER_THREADS).ok()?; + match raw.parse::() { + Ok(n) if n > 0 => Some(n), + Ok(_) => { + tracing::warn!( + "cpex-ffi: {}={} is not a positive integer; using num_cpus default", + ENV_WORKER_THREADS, + raw, + ); + None + } + Err(_) => { + tracing::warn!( + "cpex-ffi: {}={:?} is not parseable as a positive integer; using num_cpus default", + ENV_WORKER_THREADS, + raw, + ); + None + } + } +} + +/// Get (or lazily initialize on first call) the shared tokio runtime. +/// +/// On first call: respects `CPEX_FFI_WORKER_THREADS` if set. If the env +/// var is absent or invalid, defaults to tokio's `num_cpus`. The FFI +/// path `cpex_configure_runtime` overrides both — it `set`s the +/// OnceLock before this function is called, so by the time +/// `get_or_init` runs the runtime is already there and the env var is +/// ignored. +fn shared_runtime() -> &'static tokio::runtime::Runtime { + SHARED_RUNTIME.get_or_init(|| { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder.enable_all(); + if let Some(n) = worker_threads_from_env() { + builder.worker_threads(n); + tracing::info!( + "cpex-ffi: shared runtime using {} worker threads (from {})", + n, + ENV_WORKER_THREADS, + ); + } + builder + .build() + .expect("cpex-ffi: failed to build shared tokio runtime") + }) +} + +/// Configure the shared tokio runtime's worker thread count. +/// +/// Must be called *before* any `cpex_manager_new` / `cpex_manager_new_default` +/// call — once a manager has been created the runtime is fixed for +/// the process lifetime. Returns `RC_OK` on success or +/// `RC_INVALID_INPUT` if the runtime has already been initialized +/// (or if `worker_threads` is non-positive). +/// +/// Use case: multi-tenant hosts that want to bound total worker +/// threads regardless of how many `PluginManager`s are alive. +/// +/// Precedence: this FFI call beats `CPEX_FFI_WORKER_THREADS` (the env +/// var is read only on lazy init via `shared_runtime()`; an explicit +/// `set` here populates the OnceLock first and short-circuits that +/// path). Operators can set the env var as a default; host code can +/// override. +/// +/// # Safety +/// Safe to call from a single thread before any manager creation. +/// Calling after a manager exists is well-defined (returns +/// `RC_INVALID_INPUT`) but does not change the active runtime. +#[no_mangle] +pub extern "C" fn cpex_configure_runtime(worker_threads: c_int) -> c_int { + if worker_threads <= 0 { + return RC_INVALID_INPUT; + } + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_threads as usize) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + tracing::error!("cpex_configure_runtime: build failed: {}", e); + return RC_PIPELINE_ERROR; + } + }; + match SHARED_RUNTIME.set(rt) { + Ok(()) => RC_OK, + Err(_) => { + // Runtime already initialized — caller should have set + // this before any manager creation. + tracing::warn!( + "cpex_configure_runtime: runtime already initialized; \ + configuration ignored. Call before any cpex_manager_new.", + ); + RC_INVALID_INPUT + } + } +} + +/// Outcome of `run_safely`. Lets the caller distinguish timeout vs. +/// panic so they can return the right `RC_*` code instead of collapsing +/// both into a generic failure. +enum SafeRun { + Ok(T), + Timeout, + Panicked, +} + +impl SafeRun { + /// Code to return from the FFI function on a non-Ok outcome. + /// On `Ok(_)` callers continue with the wrapped value; this is only + /// consulted on the failure paths. + fn rc(&self) -> c_int { + match self { + SafeRun::Ok(_) => RC_OK, + SafeRun::Timeout => RC_TIMEOUT, + SafeRun::Panicked => RC_PANIC, + } + } +} + +/// Run a future on the shared tokio runtime with two layers of safety: +/// +/// 1. `tokio::time::timeout` bounds the total wall-clock time. A plugin +/// that blocks an OS thread (rather than awaiting cooperatively) will +/// eventually surface as `Err(Elapsed)` here instead of hanging the +/// calling goroutine forever. +/// 2. `std::panic::catch_unwind` converts any panic that escapes the +/// pipeline into an `Err`, preventing it from unwinding across the +/// `extern "C"` boundary (which is UB on Rust < 1.81 and an abort on +/// >= 1.81). +/// +/// Returns a `SafeRun` so the caller can map the failure shape to a +/// specific `RC_*` code. +fn run_safely(fut: F, op_name: &str) -> SafeRun +where + F: std::future::Future, +{ + // `tokio::time::timeout` must be constructed inside an active runtime + // context — it registers a timer with the runtime's timer driver. + // Wrap construction in an `async` block so it happens INSIDE block_on, + // not before. (Constructing it outside panics with "there is no + // reactor running".) + let result = catch_unwind(AssertUnwindSafe(|| { + shared_runtime() + .block_on(async move { tokio::time::timeout(FFI_WALL_CLOCK_TIMEOUT, fut).await }) + })); + match result { + Ok(Ok(value)) => SafeRun::Ok(value), + Ok(Err(_elapsed)) => { + tracing::error!( + "FFI {}: wall-clock timeout exceeded ({}s) — plugin likely \ + not yielding (CPU-bound or std::thread::sleep)", + op_name, + FFI_WALL_CLOCK_TIMEOUT.as_secs(), + ); + SafeRun::Timeout + } + Err(_panic_payload) => { + tracing::error!( + "FFI {}: plugin panicked across FFI boundary — caught to \ + prevent UB; returning failure to caller", + op_name, + ); + SafeRun::Panicked + } + } +} + +// --------------------------------------------------------------------------- +// Payload Type Registry +// --------------------------------------------------------------------------- + +/// Payload type IDs — must match Go constants. +pub const PAYLOAD_GENERIC: u8 = 0; +pub const PAYLOAD_CMF_MESSAGE: u8 = 1; + +/// Deserialize a MessagePack payload based on its type ID. +/// Array-indexed — O(1) lookup, zero allocation. +fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result, String> { + match payload_type { + PAYLOAD_GENERIC => { + let value: serde_json::Value = rmp_serde::from_slice(bytes) + .map_err(|e| format!("generic payload deserialize failed: {}", e))?; + Ok(Box::new(GenericPayload { value })) + } + PAYLOAD_CMF_MESSAGE => { + let msg: cpex_core::cmf::MessagePayload = rmp_serde::from_slice(bytes) + .map_err(|e| format!("CMF payload deserialize failed: {}", e))?; + Ok(Box::new(msg)) + } + _ => Err(format!("unknown payload type: {}", payload_type)), + } +} + +/// Serialize a modified payload back to MessagePack bytes. +/// Returns the payload type ID alongside the bytes so the caller +/// knows how to deserialize on the other side. +/// +/// Errors flow up so the FFI boundary can surface them as a synthetic +/// `PluginErrorRecord` in `result.errors` rather than silently dropping +/// a plugin's modification. Two failure modes: +/// - downcast didn't match any registered type (plugin returned a +/// payload not in the FFI registry) +/// - rmp_serde encoding failed (very unlikely for our known types, +/// but bubble it up rather than swallow it) +fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), String> { + // Try CMF MessagePayload first (most common) + if let Some(mp) = payload + .as_any() + .downcast_ref::() + { + return rmp_serde::to_vec_named(mp) + .map(|b| (PAYLOAD_CMF_MESSAGE, b)) + .map_err(|e| format!("CMF payload serialize failed: {e}")); + } + // Try GenericPayload + if let Some(gp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(&gp.value) + .map(|b| (PAYLOAD_GENERIC, b)) + .map_err(|e| format!("generic payload serialize failed: {e}")); + } + Err("unknown payload type, cannot serialize across FFI".to_string()) +} + +// --------------------------------------------------------------------------- +// Opaque Handle Types +// --------------------------------------------------------------------------- + +/// Opaque handle to a PluginManager. +/// +/// All managers share the process-singleton runtime returned by +/// `shared_runtime()` — see the `SHARED_RUNTIME` doc-comment for why. +pub struct CpexManagerInner { + pub manager: PluginManager, +} + +/// Opaque handle to a ContextTable (Rust-owned, not serialized). +pub struct CpexContextTableInner { + table: PluginContextTable, +} + +/// Opaque handle to BackgroundTasks (Rust-owned, not serialized). +pub struct CpexBackgroundTasksInner { + tasks: BackgroundTasks, +} + +// --------------------------------------------------------------------------- +// Helper: safe string from C +// --------------------------------------------------------------------------- + +unsafe fn c_str_to_slice<'a>(ptr: *const c_char, len: c_int) -> Option<&'a str> { + if ptr.is_null() || len <= 0 { + return None; + } + let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize); + std::str::from_utf8(bytes).ok() +} + +unsafe fn c_bytes_to_slice<'a>(ptr: *const u8, len: c_int) -> Option<&'a [u8]> { + if ptr.is_null() || len <= 0 { + return None; + } + Some(std::slice::from_raw_parts(ptr, len as usize)) +} + +/// Allocate a byte buffer and return it to the caller. +/// The caller must free it with `cpex_free_bytes`. +/// +/// Returns `(null, 0)` for empty input (`std::alloc::alloc` with size=0 +/// is UB per its docs) and for buffers that wouldn't fit in `c_int` — +/// `c_int` is i32, so a buffer >= 2 GiB would silently truncate to a +/// negative length and `cpex_free_bytes` would dealloc with the wrong +/// size, corrupting the allocator. +fn alloc_bytes(data: &[u8]) -> (*mut u8, c_int) { + let len = data.len(); + if len == 0 { + return (ptr::null_mut(), 0); + } + if len > c_int::MAX as usize { + tracing::error!( + "alloc_bytes: payload size {} exceeds c_int::MAX ({}); refusing", + len, + c_int::MAX, + ); + return (ptr::null_mut(), 0); + } + let layout = std::alloc::Layout::from_size_align(len, 1).unwrap(); + unsafe { + let ptr = std::alloc::alloc(layout); + if ptr.is_null() { + return (ptr::null_mut(), 0); + } + std::ptr::copy_nonoverlapping(data.as_ptr(), ptr, len); + (ptr, len as c_int) + } +} + +// --------------------------------------------------------------------------- +// Manager Lifecycle +// --------------------------------------------------------------------------- + +/// Create a new PluginManager from a YAML config string. +/// +/// Returns an opaque handle. The manager owns a tokio runtime for +/// async plugin execution. Returns NULL on failure. +/// +/// # Safety +/// `config_yaml` must be a valid pointer to `config_len` bytes of UTF-8. +#[no_mangle] +pub unsafe extern "C" fn cpex_manager_new( + config_yaml: *const c_char, + config_len: c_int, +) -> *mut CpexManagerInner { + let yaml = match c_str_to_slice(config_yaml, config_len) { + Some(s) => s, + None => return ptr::null_mut(), + }; + + let cpex_config = match cpex_core::config::parse_config(yaml) { + Ok(c) => c, + Err(e) => { + tracing::error!("cpex_manager_new: config parse failed: {}", e); + return ptr::null_mut(); + } + }; + + // Touch the shared runtime so any later cpex_configure_runtime + // call returns RC_INVALID_INPUT — communicates "you missed the + // window" to the operator, instead of letting the configure call + // silently no-op. + let _ = shared_runtime(); + + let manager = PluginManager::default(); + + // Load config — factories must be registered separately via cpex_register_factory + if let Err(e) = manager.load_config(cpex_config) { + tracing::error!("cpex_manager_new: load_config failed: {}", e); + return ptr::null_mut(); + } + + Box::into_raw(Box::new(CpexManagerInner { manager })) +} + +/// Create a new PluginManager with default config (no YAML). +/// +/// Useful when registering plugins programmatically. +#[no_mangle] +pub extern "C" fn cpex_manager_new_default() -> *mut CpexManagerInner { + let _ = shared_runtime(); + let manager = PluginManager::default(); + Box::into_raw(Box::new(CpexManagerInner { manager })) +} + +/// Load a YAML config into an existing manager. +/// +/// Factories must be registered before calling this function. +/// Returns 0 on success, -1 on failure. +/// +/// # Safety +/// `mgr` must be a valid handle. `config_yaml` must be valid UTF-8. +/// `mgr` is `*const` — `PluginManager::load_config` takes `&self` after +/// the ArcSwap snapshot refactor (no exclusive access needed). Two +/// callers loading config concurrently is safe; the snapshot swap is +/// atomic copy-on-write, so they see consistent state per call. +#[no_mangle] +pub unsafe extern "C" fn cpex_load_config( + mgr: *const CpexManagerInner, + config_yaml: *const c_char, + config_len: c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let yaml = match c_str_to_slice(config_yaml, config_len) { + Some(s) => s, + None => return RC_INVALID_INPUT, + }; + + let cpex_config = match cpex_core::config::parse_config(yaml) { + Ok(c) => c, + Err(e) => { + tracing::error!("cpex_load_config: config parse failed: {}", e); + return RC_PARSE_ERROR; + } + }; + + // load_config is sync (no .await), but we still wrap in catch_unwind + // so a panic in serde / config validation doesn't unwind across FFI. + let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config(cpex_config))); + match load_result { + Ok(Ok(())) => RC_OK, + Ok(Err(e)) => { + tracing::error!("cpex_load_config: load_config failed: {}", e); + RC_PIPELINE_ERROR + } + Err(_panic) => { + tracing::error!("cpex_load_config: panic caught at FFI boundary"); + RC_PANIC + } + } +} + +/// Initialize all registered plugins. +/// +/// Returns 0 on success, -1 on failure (including timeout / panic). +/// +/// # Safety +/// `mgr` must be a valid handle from `cpex_manager_new`. +/// `mgr` is `*const` — `PluginManager::initialize` takes `&self`. +#[no_mangle] +pub unsafe extern "C" fn cpex_initialize(mgr: *const CpexManagerInner) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + match run_safely(inner.manager.initialize(), "cpex_initialize") { + SafeRun::Ok(Ok(())) => RC_OK, + SafeRun::Ok(Err(e)) => { + tracing::error!("cpex_initialize: {}", e); + RC_PIPELINE_ERROR + } + other => other.rc(), // RC_TIMEOUT or RC_PANIC; already logged + } +} + +/// Shutdown all plugins and free the manager. +/// +/// `mgr` stays `*mut` here because we consume the Box (this is the one +/// place we genuinely take exclusive ownership — destroying the +/// allocation). All other entry points use `*const`. +/// +/// # Safety +/// `mgr` must be a valid handle from `cpex_manager_new`. After this +/// call, the handle is invalid and must not be used. +#[no_mangle] +pub unsafe extern "C" fn cpex_shutdown(mgr: *mut CpexManagerInner) { + if mgr.is_null() { + return; + } + let inner = Box::from_raw(mgr); + // Wrap shutdown in catch_unwind + timeout so a misbehaving plugin + // can't hang teardown forever or unwind across the FFI boundary. + // We don't have a return value here — `inner` is dropped at function + // end either way, freeing the manager and runtime. + let _ = run_safely(inner.manager.shutdown(), "cpex_shutdown"); +} + +/// Check if any plugins are registered for a hook name. +/// +/// Returns 1 (true) or 0 (false). No serialization — just a hash lookup. +/// +/// # Safety +/// `mgr` must be valid. `hook_name` must point to `hook_len` bytes of UTF-8. +#[no_mangle] +pub unsafe extern "C" fn cpex_has_hooks_for( + mgr: *const CpexManagerInner, + hook_name: *const c_char, + hook_len: c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return 0, + }; + let name = match c_str_to_slice(hook_name, hook_len) { + Some(s) => s, + None => return 0, + }; + if inner.manager.has_hooks_for(name) { + 1 + } else { + 0 + } +} + +/// Get the number of registered plugins. +/// +/// No serialization — returns an integer directly. +/// +/// # Safety +/// `mgr` must be valid. +#[no_mangle] +pub unsafe extern "C" fn cpex_plugin_count(mgr: *const CpexManagerInner) -> c_int { + match mgr.as_ref() { + Some(m) => m.manager.plugin_count() as c_int, + None => 0, + } +} + +/// Whether the manager has been initialized (i.e., `cpex_initialize` +/// returned successfully and `cpex_shutdown` has not been called). +/// +/// Returns 1 if initialized, 0 otherwise (including null mgr). +/// +/// # Safety +/// `mgr` must be valid or NULL. +#[no_mangle] +pub unsafe extern "C" fn cpex_is_initialized(mgr: *const CpexManagerInner) -> c_int { + match mgr.as_ref() { + Some(m) if m.manager.is_initialized() => 1, + _ => 0, + } +} + +/// Get the names of all registered plugins as MessagePack-encoded +/// `Vec`. Caller must free the returned bytes with +/// `cpex_free_bytes`. +/// +/// Returns an `RC_*` code; on success the names are written to +/// `*names_msgpack_out` / `*names_len_out`. +/// +/// # Safety +/// `mgr` must be valid. Output pointers must be writable. +#[no_mangle] +pub unsafe extern "C" fn cpex_plugin_names( + mgr: *const CpexManagerInner, + names_msgpack_out: *mut *mut u8, + names_len_out: *mut c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let names = inner.manager.plugin_names(); + let bytes = match rmp_serde::to_vec_named(&names) { + Ok(b) => b, + Err(_) => return RC_SERIALIZE_ERROR, + }; + let (ptr, len) = alloc_bytes(&bytes); + *names_msgpack_out = ptr; + *names_len_out = len; + RC_OK +} + +// --------------------------------------------------------------------------- +// Hook Invocation +// --------------------------------------------------------------------------- + +/// Invoke a hook by name. +/// +/// Payload and extensions are passed as MessagePack bytes. +/// ContextTable is an opaque handle (NULL for first invocation). +/// Returns MessagePack-encoded PipelineResult + opaque handles for +/// context table and background tasks. +/// +/// Returns 0 on success, -1 on failure. +/// +/// # Safety +/// All pointer parameters must be valid or NULL where documented. +/// `mgr` is `*const` — `PluginManager::invoke_by_name` takes `&self` +/// after the ArcSwap snapshot refactor. The previous `*mut` + `as_mut()` +/// shape produced aliased `&mut` references when two goroutines called +/// this concurrently — UB regardless of what the called code did. The +/// `&self` API plus `*const` here is sound for parallel dispatch. +#[no_mangle] +pub unsafe extern "C" fn cpex_invoke( + mgr: *const CpexManagerInner, + hook_name: *const c_char, + hook_len: c_int, + payload_type: u8, + payload_msgpack: *const u8, + payload_len: c_int, + extensions_msgpack: *const u8, + extensions_len: c_int, + context_table: *mut CpexContextTableInner, // NULL for first call + result_msgpack_out: *mut *mut u8, + result_len_out: *mut c_int, + context_table_out: *mut *mut CpexContextTableInner, + bg_handle_out: *mut *mut CpexBackgroundTasksInner, +) -> c_int { + // Validate manager handle + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + // Parse hook name + let name = match c_str_to_slice(hook_name, hook_len) { + Some(s) => s, + None => return RC_INVALID_INPUT, + }; + + // Deserialize payload using the type registry + let payload_bytes = match c_bytes_to_slice(payload_msgpack, payload_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + + let payload: Box = match deserialize_payload(payload_type, payload_bytes) { + Ok(p) => p, + Err(e) => { + tracing::error!("cpex_invoke: {}", e); + return RC_PARSE_ERROR; + } + }; + + // Deserialize extensions from MessagePack + let extensions: Extensions = if extensions_len > 0 { + let ext_bytes = match c_bytes_to_slice(extensions_msgpack, extensions_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + match rmp_serde::from_slice(ext_bytes) { + Ok(e) => e, + Err(e) => { + tracing::error!("cpex_invoke: extensions deserialize failed: {}", e); + return RC_PARSE_ERROR; + } + } + } else { + Extensions::default() + }; + + // Get or create context table + let ctx_table: Option = if context_table.is_null() { + None + } else { + let ct = Box::from_raw(context_table); + Some(ct.table) + }; + + // Invoke the hook with wall-clock timeout + panic catch. + let (mut result, bg) = match run_safely( + inner + .manager + .invoke_by_name(name, payload, extensions, ctx_table), + "cpex_invoke", + ) { + SafeRun::Ok(r) => r, + other => return other.rc(), // RC_TIMEOUT or RC_PANIC; already logged + }; + + // Serialize modified payload using the type registry. A failure + // here is partial — the rest of the result (continue_processing, + // violation, metadata, modified_extensions) is still valid — so + // we surface the issue as a synthetic FFI-layer record in + // `result.errors` rather than failing the whole call. This is + // uniform with how the pipeline reports plugin-level errors + // swallowed by `on_error: ignore` / `on_error: disable`. + let (result_payload_type, modified_payload_bytes) = match result.modified_payload.as_ref() { + None => (payload_type, None), + Some(p) => match serialize_payload(p.as_ref()) { + Ok((t, b)) => (t, Some(b)), + Err(e) => { + tracing::warn!("cpex_invoke: dropped modified payload — {}", e); + result.errors.push(cpex_core::error::PluginErrorRecord { + plugin_name: "".to_string(), + message: format!("modified payload could not be serialized across FFI: {e}"), + code: Some("ffi_serialize_error".to_string()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }); + (payload_type, None) + } + }, + }; + + // Serialize modified extensions if present + let modified_extensions_bytes: Option> = result + .modified_extensions + .as_ref() + .and_then(|ext| rmp_serde::to_vec_named(ext).ok()); + + // Build FFI result. `errors` flows through verbatim — it's already + // PluginErrorRecord which is the canonical wire shape. + let ffi_result = FfiPipelineResult { + continue_processing: result.continue_processing, + violation: result.violation, + errors: result.errors, + metadata: result.metadata, + payload_type: result_payload_type, + modified_payload: modified_payload_bytes, + modified_extensions: modified_extensions_bytes, + }; + + let result_bytes = match rmp_serde::to_vec_named(&ffi_result) { + Ok(b) => b, + Err(e) => { + tracing::error!("cpex_invoke: result serialize failed: {}", e); + return RC_SERIALIZE_ERROR; + } + }; + + // Return result bytes + let (ptr, len) = alloc_bytes(&result_bytes); + *result_msgpack_out = ptr; + *result_len_out = len; + + // Return context table as opaque handle + *context_table_out = Box::into_raw(Box::new(CpexContextTableInner { + table: result.context_table, + })); + + // Return background tasks as opaque handle + *bg_handle_out = Box::into_raw(Box::new(CpexBackgroundTasksInner { tasks: bg })); + + RC_OK +} + +// --------------------------------------------------------------------------- +// Background Tasks +// --------------------------------------------------------------------------- + +/// Wait for all background tasks to complete. +/// +/// Returns MessagePack-encoded errors (empty array if none). +/// Returns 0 on success, -1 on failure. +/// +/// # Safety +/// `bg_handle` must be a valid handle from `cpex_invoke`. +/// After this call, the handle is consumed and invalid. +/// `mgr` is `*const` — only the runtime is borrowed (`&self`). +#[no_mangle] +pub unsafe extern "C" fn cpex_wait_background( + mgr: *const CpexManagerInner, + bg_handle: *mut CpexBackgroundTasksInner, + errors_msgpack_out: *mut *mut u8, + errors_len_out: *mut c_int, +) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => { + // Consume `bg_handle` even on the failure path — the Go + // caller has already nil'd its reference, so without + // dropping the Box here we'd leak the BackgroundTasks + // allocation (and its still-running task handles). + if !bg_handle.is_null() { + drop(Box::from_raw(bg_handle)); + } + return RC_INVALID_HANDLE; + } + }; + + if bg_handle.is_null() { + let empty: Vec = Vec::new(); + let (ptr, len) = alloc_bytes(&rmp_serde::to_vec_named(&empty).unwrap()); + *errors_msgpack_out = ptr; + *errors_len_out = len; + return RC_OK; + } + + let bg = Box::from_raw(bg_handle); + // `inner` is now unused but the borrow proved the manager is alive + // for the duration of this call (the read lock on the Go side). + let _ = inner; + let errors = match run_safely(bg.tasks.wait_for_background_tasks(), "cpex_wait_background") { + SafeRun::Ok(errs) => errs, + other => return other.rc(), // RC_TIMEOUT or RC_PANIC; already logged + }; + + // Flatten each Rust PluginError variant into the canonical wire + // shape so Go callers get structured fields (plugin_name, code, + // details) instead of a stringified Display impl. + let ffi_errors: Vec = errors + .iter() + .map(cpex_core::error::PluginErrorRecord::from) + .collect(); + let error_bytes = match rmp_serde::to_vec_named(&ffi_errors) { + Ok(b) => b, + Err(_) => return RC_SERIALIZE_ERROR, + }; + + let (ptr, len) = alloc_bytes(&error_bytes); + *errors_msgpack_out = ptr; + *errors_len_out = len; + + RC_OK +} + +/// Free a background tasks handle without waiting. +/// +/// Tasks continue running in the tokio runtime. +/// +/// # Safety +/// `bg_handle` must be valid or NULL. +#[no_mangle] +pub unsafe extern "C" fn cpex_free_background(bg_handle: *mut CpexBackgroundTasksInner) { + if !bg_handle.is_null() { + drop(Box::from_raw(bg_handle)); + } +} + +// --------------------------------------------------------------------------- +// Context Table +// --------------------------------------------------------------------------- + +/// Free a context table handle. +/// +/// # Safety +/// `ct` must be valid or NULL. +#[no_mangle] +pub unsafe extern "C" fn cpex_free_context_table(ct: *mut CpexContextTableInner) { + if !ct.is_null() { + drop(Box::from_raw(ct)); + } +} + +// --------------------------------------------------------------------------- +// Memory Management +// --------------------------------------------------------------------------- + +/// Free a byte buffer allocated by the FFI layer. +/// +/// # Safety +/// `ptr` must have been allocated by this library (from `cpex_invoke` +/// or `cpex_wait_background`). `len` must match the original allocation. +#[no_mangle] +pub unsafe extern "C" fn cpex_free_bytes(ptr: *mut u8, len: c_int) { + if ptr.is_null() || len <= 0 { + return; + } + let layout = std::alloc::Layout::from_size_align(len as usize, 1).unwrap(); + std::alloc::dealloc(ptr, layout); +} + +// --------------------------------------------------------------------------- +// FFI Result Types — serialized to MessagePack for the caller +// --------------------------------------------------------------------------- + +/// Pipeline result serialized across the FFI boundary. +/// Matches the Go `PipelineResult` struct field names. +/// +/// `errors` carries records from `on_error: ignore` / `on_error: disable` +/// plugins so the Go caller can surface them programmatically rather +/// than parsing log output. Fire-and-forget errors come through +/// `BackgroundTasks::wait_for_background_tasks()` instead. +#[derive(serde::Serialize, serde::Deserialize)] +struct FfiPipelineResult { + continue_processing: bool, + #[serde(skip_serializing_if = "Option::is_none")] + violation: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + errors: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, + /// Payload type ID — tells the Go caller how to deserialize. + payload_type: u8, + /// Modified payload as raw MessagePack bytes (if a plugin modified it). + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(with = "serde_bytes_opt")] + modified_payload: Option>, + /// Modified extensions as raw MessagePack bytes (if a plugin modified them). + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(with = "serde_bytes_opt")] + modified_extensions: Option>, +} + +/// Helper for serializing Option> as binary in MessagePack. +mod serde_bytes_opt { + use serde::{Deserializer, Serializer}; + + pub fn serialize(v: &Option>, s: S) -> Result { + match v { + Some(bytes) => serde::Serialize::serialize(&serde_bytes::Bytes::new(bytes), s), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + use serde::Deserialize; + Option::::deserialize(d).map(|o| o.map(|b| b.into_vec())) + } +} + +// --------------------------------------------------------------------------- +// Generic Payload — wraps a deserialized MessagePack value +// --------------------------------------------------------------------------- + +/// A generic payload that wraps a deserialized serde_json::Value. +/// +/// Used for FFI dispatch when the concrete payload type isn't known +/// at compile time. The value was deserialized from MessagePack on +/// the Go side and will be passed to Rust plugins as-is. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GenericPayload { + pub value: serde_json::Value, +} + +cpex_core::impl_plugin_payload!(GenericPayload); + +// --------------------------------------------------------------------------- +// FFI unit tests +// --------------------------------------------------------------------------- +// +// These tests call the `extern "C"` functions directly from Rust to +// exercise the FFI safety layer (catch_unwind, return-code mapping, +// payload-type validation) without needing a Go test harness. The +// reviewer flagged that `cpex-ffi` had zero `#[cfg(test)]` coverage — +// this seeds the file with regressions for the highest-value invariants. + +#[cfg(test)] +mod tests { + use super::*; + use std::ptr; + use std::sync::Arc; + + use async_trait::async_trait; + use cpex_core::hooks::payload::Extensions; + use cpex_core::hooks::trait_def::HookTypeDef; + use cpex_core::hooks::PluginResult; + use cpex_core::plugin::{Plugin, PluginConfig, PluginMode}; + + // --- Test scaffolding ----------------------------------------------------- + + /// Test hook type using GenericPayload — that's the type + /// PAYLOAD_GENERIC produces at the FFI deserialization boundary, + /// so the typed-adapter downcast actually finds the handler. + /// (Defining a custom TestPayload would mean the executor's + /// downcast finds None and the handler never runs.) + struct TestHook; + impl HookTypeDef for TestHook { + type Payload = GenericPayload; + type Result = PluginResult; + const NAME: &'static str = "test_hook"; + } + + /// A plugin whose handler always panics — exercises the `catch_unwind` + /// path inside `run_safely`. + struct PanickingPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for PanickingPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + + impl cpex_core::hooks::HookHandler for PanickingPlugin { + fn handle( + &self, + _payload: &GenericPayload, + _extensions: &Extensions, + _ctx: &mut cpex_core::context::PluginContext, + ) -> PluginResult { + panic!("simulated panic from PanickingPlugin"); + } + } + + /// Build an FFI-shaped manager for testing. Bypasses + /// `cpex_manager_new` so we can register Rust plugins directly via + /// the manager's typed API. + fn build_test_manager() -> *mut CpexManagerInner { + // Touch the shared runtime so it's initialized; tests use it + // rather than a per-manager runtime. + let _ = shared_runtime(); + let manager = cpex_core::manager::PluginManager::default(); + Box::into_raw(Box::new(CpexManagerInner { manager })) + } + + fn register_panicking_plugin(mgr: &CpexManagerInner) { + let cfg = PluginConfig { + name: "panicker".into(), + kind: "test".into(), + hooks: vec!["test_hook".into()], + mode: PluginMode::Sequential, + ..Default::default() + }; + let plugin = Arc::new(PanickingPlugin { cfg: cfg.clone() }); + mgr.manager + .register_handler::(plugin, cfg) + .expect("register"); + } + + /// Encode a generic JSON value to MessagePack, the wire format + /// PAYLOAD_GENERIC consumes. Returns bytes the FFI can borrow. + fn payload_bytes(value: &str) -> Vec { + rmp_serde::to_vec_named(&serde_json::json!({ "value": value })).expect("encode payload") + } + + /// Drive cpex_invoke with a single hook name and the given payload. + /// Returns the raw rc; output buffers are dropped. + unsafe fn invoke_for_test( + mgr: *const CpexManagerInner, + payload_type: u8, + payload: &[u8], + ) -> c_int { + let hook_name = b"test_hook"; + let mut result_ptr: *mut u8 = ptr::null_mut(); + let mut result_len: c_int = 0; + let mut ct_out: *mut CpexContextTableInner = ptr::null_mut(); + let mut bg_out: *mut CpexBackgroundTasksInner = ptr::null_mut(); + + let rc = cpex_invoke( + mgr, + hook_name.as_ptr() as *const c_char, + hook_name.len() as c_int, + payload_type, + payload.as_ptr(), + payload.len() as c_int, + ptr::null(), + 0, + ptr::null_mut(), + &mut result_ptr, + &mut result_len, + &mut ct_out, + &mut bg_out, + ); + + // Drain any output buffers / handles to avoid leaks across tests. + if !result_ptr.is_null() { + cpex_free_bytes(result_ptr, result_len); + } + if !ct_out.is_null() { + cpex_free_context_table(ct_out); + } + if !bg_out.is_null() { + cpex_free_background(bg_out); + } + rc + } + + // --- Tests ---------------------------------------------------------------- + + /// Panic in a plugin must be caught at the FFI boundary and mapped + /// to `RC_PANIC` rather than unwinding across `extern "C"` (UB on + /// Rust < 1.81; abort on >= 1.81). Direct regression for P0 #2. + #[test] + fn cpex_invoke_returns_rc_panic_when_plugin_panics() { + let mgr = build_test_manager(); + // Defer cleanup so a test failure doesn't leak the manager. + struct ManagerGuard(*mut CpexManagerInner); + impl Drop for ManagerGuard { + fn drop(&mut self) { + unsafe { + cpex_shutdown(self.0); + } + } + } + let _guard = ManagerGuard(mgr); + + unsafe { + let inner = &*mgr; + register_panicking_plugin(inner); + // Manager must be initialized for the pipeline to dispatch. + let init_rc = cpex_initialize(mgr); + assert_eq!(init_rc, RC_OK, "init should succeed"); + + // Invoke with the registered hook — plugin panics, caught + // by run_safely's catch_unwind, mapped to RC_PANIC. + let bytes = payload_bytes("trigger"); + let rc = invoke_for_test(mgr, PAYLOAD_GENERIC, &bytes); + assert_eq!( + rc, RC_PANIC, + "panic should be caught and surfaced as RC_PANIC, got {}", + rc, + ); + } + } + + /// Invoking with an unknown `payload_type` must return + /// `RC_PARSE_ERROR` — the deserialize_payload registry rejects + /// unknown discriminators with a typed error code rather than a + /// generic failure. + #[test] + fn cpex_invoke_returns_rc_parse_error_on_unknown_payload_type() { + let mgr = build_test_manager(); + struct ManagerGuard(*mut CpexManagerInner); + impl Drop for ManagerGuard { + fn drop(&mut self) { + unsafe { + cpex_shutdown(self.0); + } + } + } + let _guard = ManagerGuard(mgr); + + unsafe { + let inner = &*mgr; + register_panicking_plugin(inner); // need *some* plugin so dispatch runs + assert_eq!(cpex_initialize(mgr), RC_OK); + + // Unknown payload type — dispatch never reaches the plugin. + let bytes = payload_bytes("trigger"); + let rc = invoke_for_test(mgr, 99 /* not in registry */, &bytes); + assert_eq!( + rc, RC_PARSE_ERROR, + "unknown payload_type should map to RC_PARSE_ERROR, got {}", + rc, + ); + } + } + + /// `worker_threads_from_env` parses CPEX_FFI_WORKER_THREADS into + /// a positive count, returning None for unset / zero / negative / + /// unparseable. This isolates the env-parsing logic from the + /// OnceLock-init path so we can test it deterministically. + #[test] + fn worker_threads_from_env_parses_correctly() { + // Use a unique env var name per test invocation isn't possible + // (the function reads ENV_WORKER_THREADS specifically), so we + // serialize manipulation: set, read, restore. Run-time tests + // don't currently parallelize this var across threads. + let prev = std::env::var(ENV_WORKER_THREADS).ok(); + + // SAFETY: tests are single-threaded with respect to this env + // var (no other test reads/writes it). std::env::set_var is + // unsafe in multi-threaded programs reading other env vars + // concurrently; we accept that risk in the test harness. + let restore = |v: Option| unsafe { + match v { + Some(s) => std::env::set_var(ENV_WORKER_THREADS, s), + None => std::env::remove_var(ENV_WORKER_THREADS), + } + }; + + unsafe { + std::env::set_var(ENV_WORKER_THREADS, "8"); + assert_eq!(worker_threads_from_env(), Some(8)); + + std::env::set_var(ENV_WORKER_THREADS, "0"); + assert_eq!(worker_threads_from_env(), None, "zero should fall back"); + + std::env::set_var(ENV_WORKER_THREADS, "garbage"); + assert_eq!( + worker_threads_from_env(), + None, + "unparseable should fall back" + ); + + std::env::remove_var(ENV_WORKER_THREADS); + assert_eq!(worker_threads_from_env(), None, "unset should be None"); + } + + restore(prev); + } + + /// `cpex_configure_runtime` rejects non-positive worker counts + /// before touching the shared runtime — the early bounds check + /// fires regardless of OnceLock state, so this is order-independent. + #[test] + fn cpex_configure_runtime_rejects_non_positive_workers() { + assert_eq!(cpex_configure_runtime(0), RC_INVALID_INPUT); + assert_eq!(cpex_configure_runtime(-1), RC_INVALID_INPUT); + } + + /// Once the shared runtime is initialized (e.g., by any prior test + /// or `cpex_manager_new` call), subsequent configure attempts must + /// fail with `RC_INVALID_INPUT` — the runtime is single-init. + #[test] + fn cpex_configure_runtime_after_init_returns_invalid_input() { + // Touch the runtime to ensure it's initialized. This may already + // have happened in another test; either way OnceLock is set. + let _ = shared_runtime(); + // Configure should now refuse: window has closed. + assert_eq!(cpex_configure_runtime(2), RC_INVALID_INPUT); + } + + /// `serialize_payload` returns `Ok` for known registered types so + /// modifications round-trip cleanly. + #[test] + fn serialize_payload_round_trips_generic() { + let gp = GenericPayload { + value: serde_json::json!({ "k": "v" }), + }; + let (t, bytes) = serialize_payload(&gp).expect("known type should serialize"); + assert_eq!(t, PAYLOAD_GENERIC); + // Confirm the encoded bytes deserialize back to the same shape + // — guards against silent type-id/wire-format drift. + let value: serde_json::Value = rmp_serde::from_slice(&bytes).expect("round-trip decode"); + assert_eq!(value, serde_json::json!({ "k": "v" })); + } + + /// `serialize_payload` returns `Err` for payload types the FFI + /// registry doesn't know about. Without this contract the FFI + /// silently dropped a plugin's modification — the caller saw + /// `modified_payload = None` even though one was produced. + /// This test pins the new error contract so that regression can't + /// reappear. + #[test] + fn serialize_payload_returns_err_for_unknown_type() { + // A custom PluginPayload impl that's not in the FFI registry — + // simulates a plugin returning a custom payload type the + // serializer doesn't know how to ship across the wire. + #[derive(Clone)] + struct CustomPayload; + impl PluginPayload for CustomPayload { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + } + let err = serialize_payload(&CustomPayload).expect_err("unknown type should err"); + assert!( + err.contains("unknown payload type"), + "error message should identify the failure mode, got: {err}", + ); + } + + /// A null manager handle must return `RC_INVALID_HANDLE` from + /// every entry point — guards the `as_ref()` precondition. + #[test] + fn cpex_invoke_returns_rc_invalid_handle_on_null_mgr() { + unsafe { + let bytes = payload_bytes("x"); + let rc = invoke_for_test(ptr::null(), PAYLOAD_GENERIC, &bytes); + assert_eq!(rc, RC_INVALID_HANDLE); + + assert_eq!(cpex_initialize(ptr::null()), RC_INVALID_HANDLE); + assert_eq!(cpex_is_initialized(ptr::null()), 0); + } + } +} diff --git a/crates/cpex-sdk/src/lib.rs b/crates/cpex-sdk/src/lib.rs index 6b25f15b..e4ef19f4 100644 --- a/crates/cpex-sdk/src/lib.rs +++ b/crates/cpex-sdk/src/lib.rs @@ -14,9 +14,7 @@ pub use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; // Hook system -pub use cpex_core::hooks::{ - Extensions, HookHandler, HookTypeDef, PluginPayload, PluginResult, -}; +pub use cpex_core::hooks::{Extensions, HookHandler, HookTypeDef, PluginPayload, PluginResult}; // Context pub use cpex_core::context::PluginContext; @@ -29,11 +27,25 @@ pub use cpex_core::define_hook; // CMF types pub use cpex_core::cmf::{ - // Message and payload - CmfHook, Message, MessagePayload, - // Enums - Channel, ContentType, ResourceType, Role, // Content parts and domain objects - AudioSource, ContentPart, DocumentSource, ImageSource, PromptRequest, PromptResult, Resource, - ResourceReference, ToolCall, ToolResult, VideoSource, + AudioSource, + // Enums + Channel, + // Message and payload + CmfHook, + ContentPart, + ContentType, + DocumentSource, + ImageSource, + Message, + MessagePayload, + PromptRequest, + PromptResult, + Resource, + ResourceReference, + ResourceType, + Role, + ToolCall, + ToolResult, + VideoSource, }; diff --git a/docs/specs/cpex-go-spec.md b/docs/specs/cpex-go-spec.md new file mode 100644 index 00000000..55d21757 --- /dev/null +++ b/docs/specs/cpex-go-spec.md @@ -0,0 +1,1107 @@ +# CPEX Go — Public API Specification + +**Status**: Draft +**Date**: May 2026 +**Source**: `github.com/contextforge-org/contextforge-plugins-framework/go/cpex` + +CPEX Go is the Golang consumption API for the ContextForge Plugin Extension Framework (CPEX). It embeds the Rust plugin runtime in-process via CGo/FFI, providing Go host systems with a high-performance hook-based extensibility layer. Payloads and extensions cross the FFI boundary as MessagePack bytes; plugin execution happens entirely in the Rust async runtime. + +## 1. Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ Go Host (e.g., AuthBridge) │ +│ │ +│ PluginManager ───────────────────────────────┐ │ +│ │ NewPluginManager[Default]() │ │ +│ │ RegisterFactories(fn) │ │ +│ │ LoadConfig(yaml) │ │ +│ │ Initialize() │ │ +│ │ InvokeByName(hook, payload, ext, ctx) │ │ +│ │ Invoke[P](hook, payload, ext, ctx) │ │ +│ │ HasHooksFor(hook) / PluginCount() │ │ +│ │ Shutdown() │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ CGo / MessagePack │ +├────────────────────────┼─────────────────────────────┤ +│ libcpex_ffi (Rust) ▼ │ +│ cpex_manager_new / cpex_invoke / cpex_shutdown │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ cpex-core (Rust) │ │ +│ │ • PluginManager → Executor → Plugins │ │ +│ │ • tokio runtime (async plugin execution) │ │ +│ │ • Phase ordering, capability gating │ │ +│ │ • Route resolution, policy composition │ │ +│ └─────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +**Key design decisions:** + +- Plugins are written in Rust (native) and compiled into `libcpex_ffi`. The Go layer is the host embedding API, not a plugin authoring API. +- The FFI boundary uses MessagePack for payloads/extensions and opaque handles for stateful objects (ContextTable, BackgroundTasks). +- All `PluginManager`s in a process share a single tokio runtime (process-singleton via `OnceLock`). Async plugin execution works from synchronous CGo calls without exploding thread count under multi-tenant hosts. Worker thread count is configurable — see §5.8. + +## 2. Package & Import + +```go +import cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +``` + +**Dependencies:** + +| Dependency | Purpose | +|---|---| +| `github.com/vmihailenco/msgpack/v5` | MessagePack serialization across FFI | + +**Build requirements:** + +```bash +# Build the Rust FFI library first +cargo build --release -p cpex-ffi + +# Then build/test Go code +go test -v ./... +``` + +CGo links against `libcpex_ffi` from `target/release/`. + +## 3. Lifecycle + +``` +[ConfigureRuntime(N)] ← optional, package-level, before any manager + │ + ▼ +NewPluginManagerDefault() + │ + ▼ +RegisterFactories(fn) ← register Rust plugin factories via callback + │ + ▼ +LoadConfig(yaml) ← YAML with plugin definitions, routing, policies + │ + ▼ +Initialize() ← instantiate and wire all plugins + │ + ▼ +InvokeByName / Invoke[P] ← dispatch hooks (repeatable) + │ + ▼ +Shutdown() ← graceful teardown +``` + +## 4. Quick Reference + +| Operation | Method | +|---|---| +| Configure runtime (optional) | `cpex.ConfigureRuntime(workerThreads)` | +| Create manager | `NewPluginManagerDefault()` or `NewPluginManager(yaml)` | +| Register factories | `mgr.RegisterFactories(fn)` | +| Load config | `mgr.LoadConfig(yaml)` | +| Initialize | `mgr.Initialize()` | +| Query lifecycle | `mgr.IsInitialized()` | +| Check hooks exist | `mgr.HasHooksFor(hookName)` | +| Count plugins | `mgr.PluginCount()` | +| List plugins | `mgr.PluginNames()` | +| Invoke (untyped) | `mgr.InvokeByName(hook, type, payload, ext, ctx)` | +| Invoke (typed) | `Invoke[P](mgr, hook, type, payload, ext, ctx)` | +| Check denial | `result.IsDenied()` | +| Get violation | `result.Violation` | +| Get pipeline errors | `result.Errors` (from `on_error: ignore`/`disable` plugins) | +| Thread context | Pass returned `*ContextTable` to next invoke | +| Wait background | `bg.Wait()` returns `([]PluginError, error)` | +| Release background | `bg.Close()` | +| Classify error | `errors.Is(err, ErrCpexTimeout)` (and other sentinels — §14) | +| Shutdown | `mgr.Shutdown()` | + +## 5. Core Types + +### 5.1 PluginManager + +The top-level object. Owns the Rust runtime and plugin registry. + +```go +type PluginManager struct { /* opaque CGo handle, sync.RWMutex */ } + +// Construction +func NewPluginManager(yaml string) (*PluginManager, error) +func NewPluginManagerDefault() (*PluginManager, error) + +// Factory registration +func (m *PluginManager) RegisterFactories(fn FactoryRegistrar) error + +// Configuration +func (m *PluginManager) LoadConfig(yaml string) error + +// Initialization +func (m *PluginManager) Initialize() error + +// Query +func (m *PluginManager) HasHooksFor(hookName string) bool +func (m *PluginManager) PluginCount() int +func (m *PluginManager) IsInitialized() bool +func (m *PluginManager) PluginNames() ([]string, error) + +// Invocation +func (m *PluginManager) InvokeByName( + hookName string, + payloadType uint8, + payload any, + extensions *Extensions, + contextTable *ContextTable, +) (*PipelineResult, *ContextTable, *BackgroundTasks, error) + +// Typed invocation (generics) +func Invoke[P any]( + m *PluginManager, + hookName string, + payloadType uint8, + payload P, + extensions *Extensions, + contextTable *ContextTable, +) (*TypedPipelineResult[P], *ContextTable, *BackgroundTasks, error) + +// Teardown +func (m *PluginManager) Shutdown() +``` + +**Notes:** +- `NewPluginManager(yaml)` creates the manager AND loads config in one call (factories auto-registered). +- `NewPluginManagerDefault()` creates an empty manager — call `RegisterFactories` then `LoadConfig` separately. +- A Go finalizer calls `Shutdown()` if the caller forgets, but explicit `Shutdown()` is recommended. +- The `PluginManager` wrapper holds a `sync.RWMutex` so `Shutdown()` cannot race with concurrent `Invoke*` calls; Operation methods take the read lock, lifecycle methods take the write lock. +- `PluginNames()` returns the registered plugin names in registration order (no guaranteed sort). + +### 5.2 FactoryRegistrar + +```go +type FactoryRegistrar func(handle unsafe.Pointer) error +``` + +A callback that receives the raw C manager handle. The caller uses this to invoke their own `extern "C"` factory registration function. This is the bridge for registering custom Rust plugin factories that are compiled into a separate shared library. + +**Example:** + +```go +/* +#include +int my_register_factories(void* mgr); +*/ +import "C" + +err := mgr.RegisterFactories(func(handle unsafe.Pointer) error { + rc := C.my_register_factories(handle) + if rc != 0 { + return fmt.Errorf("factory registration failed: %d", rc) + } + return nil +}) +``` + +### 5.3 ContextTable + +```go +type ContextTable struct { /* opaque CGo handle */ } +func (ct *ContextTable) Close() +``` + +Per-plugin state that persists across hook invocations within a single request. Thread the returned `ContextTable` from one `Invoke` call into the next to maintain plugin-local context. + +- Pass `nil` on the first invocation. +- After use, the handle is consumed by the next `Invoke` call (ownership transfers to Rust). +- Call `Close()` to release without further use. + +### 5.4 BackgroundTasks + +```go +type BackgroundTasks struct { /* opaque CGo handle */ } +func (bg *BackgroundTasks) Wait() ([]PluginError, error) +func (bg *BackgroundTasks) Close() +``` + +Handle to fire-and-forget tasks spawned by plugins (e.g., async audit logging). Tasks run in the shared Rust tokio runtime outside the request's latency budget. + +- `Wait()` blocks until all background tasks complete. Returns a structured `[]PluginError` from any that failed (typed shape — `PluginName`, `Code`, `Message`, etc.; see §5.7) plus an `error` for FFI-level failures (e.g., the manager was shut down between invoke and wait — returns `ErrCpexInvalidHandle`). +- `Close()` releases the handle without waiting — tasks continue running. +- The handle holds a `*PluginManager` reference and checks `mgr.handle != nil` under the manager's lock before calling into Rust, so `Wait()` after `Shutdown()` is safe (returns `ErrCpexInvalidHandle` rather than dereferencing freed memory). + +### 5.5 PipelineResult + +```go +type PipelineResult struct { + ContinueProcessing bool + Violation *PluginViolation + Metadata map[string]any + PayloadType uint8 + ModifiedPayload []byte // raw MessagePack + ModifiedExtensions []byte // raw MessagePack + Errors []PluginError // see §5.7 +} + +func (r *PipelineResult) IsDenied() bool +func (r *PipelineResult) DeserializeExtensions() (*Extensions, error) +func DeserializePayload[T any](result *PipelineResult) (*T, error) +``` + +`Errors` carries structured records for failures from plugins that ran with `on_error: ignore` or `on_error: disable` — previously these were only logged and invisible to callers. Use them to drive retry logic, dashboards, or audit trails. See §13.6 for the consumption pattern, and §5.7 for the synthetic FFI-layer record. + +### 5.6 TypedPipelineResult + +```go +type TypedPipelineResult[P any] struct { + ContinueProcessing bool + Violation *PluginViolation + Metadata map[string]any + PayloadType uint8 + ModifiedPayload *P + ModifiedExtensions *Extensions + Errors []PluginError +} + +func (r *TypedPipelineResult[P]) IsDenied() bool +``` + +The typed invoke path (`Invoke[P]`) automatically deserializes the modified payload and extensions into concrete Go types. `Errors` is the same shape as `PipelineResult.Errors`. + +### 5.7 PluginError + +```go +type PluginError struct { + PluginName string `msgpack:"plugin_name"` + Message string `msgpack:"message"` + Code string `msgpack:"code,omitempty"` + Details map[string]any `msgpack:"details,omitempty"` + ProtoErrorCode *int64 `msgpack:"proto_error_code,omitempty"` +} +``` + +Structured plugin failure record. Used by `PipelineResult.Errors`, `TypedPipelineResult[P].Errors`, and `BackgroundTasks.Wait()`. All entries are framework-emitted — plugins influence the record (via the error they return) but cannot forge `PluginName`, which is set by the executor from the registered plugin metadata. + +**Reserved synthetic plugin names:** + +| `PluginName` | Source | +|---|---| +| `` | Framework-emitted at the FFI boundary. Currently issued when a plugin's modified payload cannot be re-serialized across the wire (`Code: "ffi_serialize_error"`). The rest of the result remains valid; the failure is surfaced via `Errors` rather than failing the whole call. | + +Filter or branch by `PluginName == ""` if your host wants to distinguish FFI-layer failures from plugin-emitted failures. + +### 5.8 PluginViolation + +```go +type PluginViolation struct { + Code string + Reason string + Description string + Details map[string]any + PluginName string + ProtoErrorCode *int64 +} +``` + +Structured denial. `Code` is a machine-readable identifier; `Reason` is a short human-readable explanation. + +### 5.9 ConfigureRuntime (package-level) + +```go +func ConfigureRuntime(workerThreads int) error +``` + +Sets the worker thread count for the shared tokio runtime that backs every `PluginManager` in the process. **Must** be called before the first `NewPluginManager*` — once a manager has been created the runtime is fixed for process lifetime. + +```go +// In main(), before any manager construction: +if err := cpex.ConfigureRuntime(8); err != nil { + log.Fatal(err) // returns ErrCpexInvalidInput on <=0 or after init +} +``` + +**Precedence (highest first):** + +1. `ConfigureRuntime(N)` — explicit FFI call, before first use. +2. `CPEX_FFI_WORKER_THREADS` env var — operator-friendly default. Read once on lazy init. +3. tokio default (`num_cpus`) — when neither knob is set. + +Use case: multi-tenant hosts that want to bound total worker threads regardless of how many `PluginManager`s are alive (one per tenant, dynamic plugin reload, etc.). Without this knob, N managers × `num_cpus` workers each can blow up the OS thread count. + +## 6. Extensions + +Extensions carry capability-gated metadata alongside the payload. Each plugin sees only the extensions its declared capabilities grant. Serialized as MessagePack across the FFI boundary. + +```go +type Extensions struct { + Meta *MetaExtension + Security *SecurityExtension + Http *HttpExtension + Delegation *DelegationExtension + Agent *AgentExtension + Request *RequestExtension + MCP *MCPExtension + Completion *CompletionExtension + Provenance *ProvenanceExtension + LLM *LLMExtension + Framework *FrameworkExtension + Custom map[string]any +} +``` + +### 6.1 Extension Types + +| Extension | Purpose | Key Fields | +|---|---|---| +| `Meta` | Entity identification for route resolution | `EntityType`, `EntityName`, `Tags`, `Scope`, `Properties` | +| `Security` | Identity, labels, data policies | `Subject`, `Agent`, `Labels`, `Classification`, `AuthMethod`, `Objects`, `Data` | +| `Http` | HTTP request/response context | `RequestHeaders`, `ResponseHeaders` | +| `Delegation` | Token delegation chain | `Chain[]`, `Depth`, `OriginSubjectID`, `ActorSubjectID` | +| `Agent` | Agent execution context | `Input`, `SessionID`, `ConversationID`, `Turn` (`*uint32`), `AgentID`, `ParentAgentID`, `Conversation` (`*ConversationContext`) | +| `Request` | Execution environment and tracing | `Environment`, `RequestID`, `TraceID`, `SpanID`, `Timestamp` | +| `MCP` | MCP entity metadata | `Tool`, `Resource`, `Prompt` | +| `Completion` | LLM completion stats | `StopReason`, `Tokens`, `Model`, `RawFormat`, `CreatedAt`, `LatencyMs` | +| `Provenance` | Origin and message threading | `Source`, `MessageID`, `ParentID` | +| `LLM` | Model identity | `ModelID`, `Provider`, `Capabilities` | +| `Framework` | Agentic framework context | `Framework`, `FrameworkVersion`, `NodeID`, `GraphID` | +| `Custom` | Arbitrary key-value pairs | `map[string]any` | + +### 6.2 Security Extension Detail + +```go +type SecurityExtension struct { + Labels []string + Classification string + Subject *SubjectExtension // authenticated caller + Agent *AgentIdentity // this agent's workload identity + AuthMethod string + Objects map[string]ObjectSecurityProfile + Data map[string]DataPolicy +} + +type SubjectExtension struct { + ID, SubjectType string + Roles, Permissions, Teams []string + Claims map[string]string +} + +type AgentIdentity struct { + ClientID, WorkloadID, TrustDomain string +} +``` + +### 6.3 Delegation Extension Detail + +```go +type DelegationExtension struct { + Chain []DelegationHop + Depth int + OriginSubjectID string + ActorSubjectID string + Delegated bool + AgeSeconds float64 +} + +type DelegationHop struct { + SubjectID, SubjectType, Audience, Strategy, Timestamp string + ScopesGranted []string + TTLSeconds *uint64 + FromCache bool +} +``` + +### 6.4 Capability-Gated Writes (Rust Plugin Side) + +The `capabilities` list in a plugin's YAML config controls which extension fields the plugin can read **and** write. The Rust executor translates declared capabilities into write tokens before calling `Plugin::handle`. A plugin that lacks `write_headers`, for example, receives `http_write_token: None` and cannot modify `HttpExtension`. + +The Rust write pattern uses COW (copy-on-write) ownership: + +```rust +// In Plugin::handle — capability-gated extension modification +let mut owned = extensions.cow_copy(); // clone mutable slots + +if let Some(ref token) = owned.http_write_token { // token present iff capability declared + if let Some(http) = owned.http.as_mut() { + let h = http.write(token); + h.set_response_header("X-Tool-Name", name); + h.set_response_header("X-CPEX-Processed", "true"); + } +} + +PluginResult::modify_extensions(owned) // emit modified extensions back to Go +``` + +On the Go side, `result.ModifiedExtensions` (or `typed.ModifiedExtensions`) carries the updated extensions returned by the plugin. The Go caller can deserialize them with `result.DeserializeExtensions()` (see §13.3). + +**Rust `PluginResult` constructors:** + +| Constructor | What it signals | +|---|---| +| `PluginResult::allow()` | Pass, no changes | +| `PluginResult::deny(violation)` | Halt pipeline, return violation to Go | +| `PluginResult::modify_extensions(owned)` | Pass, return modified extensions | +| `PluginResult::modify_payload(payload)` | Pass, return modified payload | + +## 7. Payload Types + +### 7.1 Payload Type Registry + +CPEX uses a `payloadType` discriminator to tell the Rust core how to deserialize the payload: + +| Constant | Value | Payload Type | +|---|---|---| +| `PayloadGeneric` | `0` | `map[string]any` — untyped JSON-like payload | +| `PayloadCMFMessage` | `1` | `MessagePayload` — CMF message | + +Hosts define their own payload structs (e.g., `InboundPreValidationPayload`) and serialize them as `PayloadGeneric`. The type ID tells Rust how to deserialize; Go callers choose the ID and matching struct. + +### 7.2 Generic Payload + +Any `map[string]any` or struct with msgpack tags. Serialized as MessagePack, deserialized in Rust as a `serde_json::Value`. + +```go +payload := map[string]any{ + "tool_name": "get_compensation", + "user": "alice", +} +result, ct, bg, err := mgr.InvokeByName("tool_pre_invoke", cpex.PayloadGeneric, payload, ext, nil) +``` + +### 7.3 CMF MessagePayload + +The ContextForge Message Format — a typed, multi-part message with schema versioning. + +```go +type MessagePayload struct { + Message Message `msgpack:"message"` +} + +type Message struct { + SchemaVersion string `msgpack:"schema_version"` + Role string `msgpack:"role"` + Content []ContentPart `msgpack:"content"` + Channel string `msgpack:"channel,omitempty"` +} + +func NewMessage(role string, content ...ContentPart) Message +``` + +### 7.4 Content Parts + +`ContentPart` is a tagged union discriminated by `content_type`. Custom msgpack encoding produces the same wire format as Rust's `#[serde(tag = "content_type")]`. + +| Content Type | Constructor | Data Field | +|---|---|---| +| `text` | `NewTextPart(s)` | `.Text` | +| `thinking` | `NewThinkingPart(s)` | `.Text` | +| `tool_call` | `NewToolCallPart(tc)` | `.ToolCallContent` | +| `tool_result` | `NewToolResultPart(tr)` | `.ToolResultContent` | +| `resource` | `NewResourcePart(r)` | `.ResourceContent` | +| `resource_ref` | `NewResourceRefPart(r)` | `.ResourceRefContent` | +| `prompt_request` | `NewPromptRequestPart(pr)` | `.PromptRequestContent` | +| `prompt_result` | `NewPromptResultPart(pr)` | `.PromptResultContent` | +| `image` | `NewImagePart(img)` | `.ImageContent` | +| `video` | `NewVideoPart(vid)` | `.VideoContent` | +| `audio` | `NewAudioPart(aud)` | `.AudioContent` | +| `document` | `NewDocumentPart(doc)` | `.DocumentContent` | + +Constructors follow Go's `NewXyz` convention so they don't shadow the like-named struct fields on `ContentPart` (e.g., the `ToolCallContent *ToolCall` field vs the `NewToolCallPart` constructor). + +Unknown `content_type` discriminators are preserved on decode via an internal `rawMap` and re-emitted unchanged on encode — so a Go host running an older SDK against a newer Rust runtime won't silently drop content parts it doesn't recognize. + +**Example:** + +```go +msg := cpex.MessagePayload{ + Message: cpex.NewMessage("assistant", + cpex.NewTextPart("Looking up compensation data"), + cpex.NewToolCallPart(cpex.ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + }), + ), +} + +result, ct, bg, err := cpex.Invoke[cpex.MessagePayload]( + mgr, "cmf.tool_pre_invoke", cpex.PayloadCMFMessage, msg, ext, nil, +) +``` + +## 8. Hook Types (Built-in) + +Hooks are open strings — hosts define their own. The following are built into `cpex-core`: + +### 8.1 Legacy Hooks (typed payloads) + +| Hook Name | Lifecycle Stage | +|---|---| +| `tool_pre_invoke` | Before tool execution | +| `tool_post_invoke` | After tool execution | +| `prompt_pre_fetch` | Before prompt template fetch | +| `prompt_post_fetch` | After prompt template fetch | +| `resource_pre_fetch` | Before resource fetch | +| `resource_post_fetch` | After resource fetch | +| `identity_resolve` | Identity resolution | +| `token_delegate` | Token delegation | + +### 8.2 CMF Hooks (MessagePayload) + +| Hook Name | Lifecycle Stage | +|---|---| +| `cmf.tool_pre_invoke` | Before tool execution (CMF message) | +| `cmf.tool_post_invoke` | After tool execution (CMF message) | +| `cmf.llm_input` | Before LLM call | +| `cmf.llm_output` | After LLM response | +| `cmf.prompt_pre_fetch` | Before prompt fetch (CMF) | +| `cmf.prompt_post_fetch` | After prompt fetch (CMF) | +| `cmf.resource_pre_fetch` | Before resource fetch (CMF) | +| `cmf.resource_post_fetch` | After resource fetch (CMF) | + +### 8.3 Custom Hooks + +Hosts register their own hook names. Any string works: + +```go +mgr.InvokeByName("inbound.pre_validation", cpex.PayloadGeneric, payload, ext, nil) +mgr.InvokeByName("outbound.pre_exchange", cpex.PayloadGeneric, payload, ext, nil) +``` + +## 9. Plugin Configuration (YAML) + +Plugins are declared in YAML and loaded via `LoadConfig`. The YAML is parsed by the Rust core. + +```yaml +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + all: + plugins: [identity-checker] + pii: + plugins: [pii-guard] + +plugins: + - name: identity-checker + kind: builtin/identity + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + capabilities: + - read_labels + - read_subject + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + + - name: header-injector + kind: builtin/cmf-header-injector + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 50 + on_error: ignore + capabilities: + - read_headers + - write_headers + +routes: + # Tool-specific route — tags applied to all invocations of this tool + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + - tool: list_departments + plugins: + - audit-logger + + # Wildcard route — applies to all tools not matched above + - tool: "*" + plugins: + - audit-logger +``` + +### 9.1 Plugin Modes + +| Mode | Behavior | +|---|---| +| `sequential` | Serial execution, can block (deny) AND modify payload | +| `transform` | Serial execution, can modify payload but cannot block | +| `audit` | Serial execution, read-only (no modify, no block) | +| `concurrent` | Parallel execution, can block but cannot modify | +| `fire_and_forget` | Background execution, non-blocking, runs after pipeline completes | +| `disabled` | Plugin loaded but not executed | + +### 9.2 Error Handling (`on_error`) + +| Value | Behavior | +|---|---| +| `fail` | Halt pipeline, propagate error to caller | +| `ignore` | Log error, continue pipeline | +| `disable` | Log error, disable plugin for remaining lifetime, continue | + +### 9.3 Plugin Capabilities + +The optional `capabilities` list controls which extension fields a plugin can read and write. The Rust executor passes write tokens only for declared capabilities; undeclared extension slots arrive as `None` in the plugin's `handle()` call. + +| Capability | Extensions access granted | +|---|---| +| `read_labels` | `SecurityExtension.labels` (read) | +| `read_subject` | `SecurityExtension.subject` (read) | +| `read_headers` | `HttpExtension.request_headers` (read) | +| `write_headers` | `HttpExtension.response_headers` (read + write token) | + +Capabilities declared in YAML are enforced at the Rust core level — a plugin cannot write to extensions it did not declare. See §6.4 for the Rust-side write pattern. + +### 9.4 Routes + +Routes match invocations by tool name and apply additional plugin overrides or tag injection. Evaluated in order; first match wins. The `"*"` wildcard matches any tool not matched by an earlier route. + +```yaml +routes: + # Exact match — injects meta tags for this tool's invocations + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + # Exact match — no meta tags + - tool: list_departments + plugins: + - audit-logger + + # Wildcard — catch-all for remaining tools + - tool: "*" + plugins: + - audit-logger +``` + +The `meta.tags` field under a route entry augments (or sets) the `MetaExtension.Tags` seen by plugins for that tool, enabling tag-based policy groups to trigger without requiring the Go caller to set tags on every invocation. + +## 10. Integration Pattern + +The canonical integration pattern for a Go host: + +```go +package main + +import ( + "fmt" + "os" + "unsafe" + + cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +) + +/* +// macOS: add -framework CoreFoundation -framework Security +// Linux: -lm -ldl -lpthread are sufficient +#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lmy_plugins_ffi -lm -ldl -lpthread +#include +int my_register_factories(void* mgr); +*/ +import "C" + +func main() { + // 1. Create manager + mgr, err := cpex.NewPluginManagerDefault() + if err != nil { + panic(err) + } + defer mgr.Shutdown() + + // 2. Register custom plugin factories + if err := mgr.RegisterFactories(func(handle unsafe.Pointer) error { + if C.my_register_factories(handle) != 0 { + return fmt.Errorf("factory registration failed") + } + return nil + }); err != nil { + panic(err) + } + + // 3. Load configuration + yaml, err := os.ReadFile("plugins.yaml") + if err != nil { + panic(err) + } + if err := mgr.LoadConfig(string(yaml)); err != nil { + panic(err) + } + + // 4. Initialize plugins + if err := mgr.Initialize(); err != nil { + panic(err) + } + + // 5. Invoke hooks in the request lifecycle + ext := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii"}, + }, + Security: &cpex.SecurityExtension{ + Subject: &cpex.SubjectExtension{ + ID: "user-123", + Roles: []string{"analyst"}, + }, + }, + } + + result, ct, bg, err := mgr.InvokeByName( + "tool_pre_invoke", cpex.PayloadGeneric, + map[string]any{"tool_name": "get_compensation", "user": "alice"}, + ext, nil, + ) + if err != nil { + panic(err) + } + + if result.IsDenied() { + fmt.Printf("Denied: %s [%s]\n", result.Violation.Reason, result.Violation.Code) + ct.Close() + bg.Close() + return + } + + // 6. Thread context into post-invoke + result2, ct2, bg2, err := mgr.InvokeByName( + "tool_post_invoke", cpex.PayloadGeneric, + map[string]any{"tool_name": "get_compensation", "result": "..."}, + ext, ct, // pass context from pre-invoke + ) + if err != nil { + panic(err) + } + _ = result2 + bg.Close() + bg2.Close() + ct2.Close() +} +``` + +## 11. Typed Invoke Pattern + +For hosts using CMF messages or custom structs with strong typing: + +```go +// Define a custom payload type with msgpack tags +type InboundPreValidationPayload struct { + Path string `msgpack:"path"` + Audience string `msgpack:"audience"` +} + +// Invoke with type safety +result, ct, bg, err := cpex.Invoke[InboundPreValidationPayload]( + mgr, + "inbound.pre_validation", + cpex.PayloadGeneric, // serialized as generic msgpack + InboundPreValidationPayload{Path: "/api/v1/users", Audience: "my-api"}, + ext, + nil, +) +if err != nil { /* handle */ } + +// result.ModifiedPayload is *InboundPreValidationPayload (or nil if unmodified) +if result.ModifiedPayload != nil { + fmt.Println("Modified audience:", result.ModifiedPayload.Audience) +} +``` + +## 12. Zero-Cost Guard Pattern + +Check for registered plugins before constructing payloads: + +```go +if !mgr.HasHooksFor("inbound.pre_validation") { + // No plugins configured — skip payload construction and FFI overhead + return handleRequestDirectly(req) +} + +// Only build payload and extensions if plugins are registered +payload := buildPreValidationPayload(req) +ext := buildExtensions(req) +result, ct, bg, err := mgr.InvokeByName("inbound.pre_validation", ...) +``` + +This pattern ensures zero cost when no plugins are configured for a hook point. + +## 13. Result Handling + +### 13.1 Allow/Deny + +```go +result, ct, bg, err := mgr.InvokeByName(...) +if result.IsDenied() { + // Pipeline halted by a plugin + v := result.Violation + return denyResponse(v.Code, v.Reason, v.Description) +} +// Proceed with original or modified payload +``` + +### 13.2 Modified Payload + +```go +// Raw path — manual deserialization +if len(result.ModifiedPayload) > 0 { + modified, err := cpex.DeserializePayload[MyPayload](result) + // use modified +} + +// Typed path — automatic deserialization +typed, ct, bg, err := cpex.Invoke[MyPayload](mgr, hook, payloadType, payload, ext, nil) +if typed.ModifiedPayload != nil { + // use typed.ModifiedPayload directly +} +``` + +### 13.3 Modified Extensions + +```go +if len(result.ModifiedExtensions) > 0 { + ext, _ := result.DeserializeExtensions() + // Plugins may have enriched Security.Subject, added Labels, etc. +} +``` + +### 13.4 Background Tasks + +```go +// Option A: Wait for background tasks (e.g., at request boundary) +bgErrors, err := bg.Wait() +if err != nil { + // FFI-level failure — e.g., ErrCpexInvalidHandle if the manager + // was shut down between Invoke and Wait. The handle is still + // safely consumed; no need to call Close after a Wait error. + log.Warn("bg.Wait failed:", err) +} +for _, e := range bgErrors { + log.Warn("background task error: plugin=%s code=%s msg=%s", + e.PluginName, e.Code, e.Message) +} + +// Option B: Fire and forget +bg.Close() +``` + +### 13.5 Metadata + +```go +if result.Metadata != nil { + // Aggregate metadata from all plugins in the chain + if decision, ok := result.Metadata["_decision_plugin"]; ok { + log.Info("decided by:", decision) + } +} +``` + +### 13.6 Pipeline Errors (ignore / disable) + +When a plugin fails and its `on_error` mode is `ignore` or `disable`, the pipeline continues and the failure is recorded in `result.Errors` rather than halting via `result.Violation`. This is the canonical surface for non-fatal plugin errors that callers may still want to act on. + +```go +result, ct, bg, err := mgr.InvokeByName(...) +if err != nil { /* FFI-level error */ } +if result.IsDenied() { /* halted by a fail/deny plugin */ } + +// Pipeline ran to completion. Inspect any soft errors. +for _, e := range result.Errors { + if e.PluginName == "" { + // Framework-emitted — e.g., the modified payload couldn't be + // re-serialized across the FFI boundary. The rest of the + // result is still valid; the plugin's modification was + // dropped. + metrics.Inc("cpex.ffi_serialize_error") + } else { + // Plugin-attributed — the plugin failed but ran with + // on_error: ignore/disable, so we got here. Code is the + // plugin's machine-readable identifier. + log.Warn("plugin %s failed [%s]: %s", e.PluginName, e.Code, e.Message) + } +} +``` + +Note that `result.Errors` is *separate* from `result.Violation` — a violation halts the pipeline (no further plugins run); errors recorded here mean the pipeline kept going. + +## 14. Error Handling + +CPEX Go classifies errors via typed sentinels. Use `errors.Is(err, ErrCpexX)` rather than string-matching `err.Error()` — the message text is not part of the API. + +### 14.1 Sentinels + +```go +var ( + // ErrCpexInvalidHandle: the manager handle is null or the + // manager was shut down. Returned when calling methods on a + // shut-down manager, or when BackgroundTasks.Wait runs after + // Shutdown. + ErrCpexInvalidHandle = errors.New("cpex: invalid handle ...") + + // ErrCpexInvalidInput: caller-supplied input was malformed — + // bad UTF-8 in hookName, payloadType out of range, oversized + // buffer, etc. Calling code bug. + ErrCpexInvalidInput = errors.New("cpex: invalid input") + + // ErrCpexParse: parse / deserialize failed (YAML config, + // MessagePack payload, MessagePack extensions). Often a wire + // format mismatch between Go and Rust struct definitions. + ErrCpexParse = errors.New("cpex: parse / deserialize failed") + + // ErrCpexPipeline: pipeline / lifecycle step failed — + // load_config returned Err, initialize returned Err, or a + // plugin signalled failure during invoke (without timeout or + // panic). The plugin's structured error is in result.Errors + // when on_error is ignore/disable. + ErrCpexPipeline = errors.New("cpex: pipeline / lifecycle error") + + // ErrCpexSerialize: result serialization failed after the + // pipeline ran — usually OOM on rmp_serde::to_vec_named, or an + // unserializable JSON value. Distinct from the per-modified- + // payload synthetic error in result.Errors (see §5.7). + ErrCpexSerialize = errors.New("cpex: result serialize failed") + + // ErrCpexTimeout: the FFI wall-clock timeout (60s) was + // exceeded. A plugin is likely CPU-bound or blocking the OS + // thread without yielding. Rust per-plugin timeouts only + // catch cooperative-async timeouts; this catches the rest. + ErrCpexTimeout = errors.New("cpex: wall-clock timeout") + + // ErrCpexPanic: a plugin panicked; caught by catch_unwind at + // the FFI boundary. Indicates a bug in plugin Rust code. + ErrCpexPanic = errors.New("cpex: plugin panicked") +) +``` + +### 14.2 Classification Pattern + +```go +result, ct, bg, err := mgr.InvokeByName(...) +if err != nil { + switch { + case errors.Is(err, cpex.ErrCpexTimeout): + metrics.Inc("cpex.timeout") + return retryWithBackoff(req) + case errors.Is(err, cpex.ErrCpexPanic): + // Plugin bug — log, alert, fail closed. + metrics.Inc("cpex.panic") + return denyOnPluginPanic() + case errors.Is(err, cpex.ErrCpexInvalidHandle): + // Manager has been shut down — recreate or fail closed. + return errors.New("plugin runtime offline") + default: + // ErrCpexParse / Serialize / Pipeline / InvalidInput — + // typically caller or config bugs. + log.Error("cpex invoke:", err) + return err + } +} +``` + +### 14.3 Two error channels + +CPEX Go reports failures through two distinct channels, and they have different semantics: + +| Channel | Triggers | Meaning | +|---|---|---| +| `error` return value | FFI-level failures (timeout, panic, parse, invalid handle) | The pipeline did not complete usefully — `result` is `nil` | +| `result.Errors` | Plugin failures with `on_error: ignore` or `on_error: disable`; FFI-layer modified-payload serialize failures | The pipeline ran to completion — `result` is valid; treat as soft errors | + +A pipeline can return `err == nil`, `result.IsDenied() == false`, AND non-empty `result.Errors`. That means: "everything ran, nothing halted, but here are the things that didn't work." Don't ignore `result.Errors` just because `err` was nil. + +## 15. Serialization + +All types use `msgpack` struct tags matching Rust field names for zero-copy serialization across the FFI boundary. The wire format is MessagePack with named fields (`rmp_serde::to_vec_named` on the Rust side). + +**Rules:** +- Go struct fields map 1:1 to Rust struct fields via `msgpack:"field_name"` tags. +- Optional fields use `omitempty` — nil/zero values are not serialized. +- `ContentPart` uses custom `EncodeMsgpack`/`DecodeMsgpack` for tagged-union encoding. +- Byte slices (`[]byte`) are serialized as MessagePack binary, not arrays. + +## 16. Thread Safety + +- `PluginManager` is safe for concurrent use from multiple goroutines. The Go wrapper holds a `sync.RWMutex` so concurrent `Invoke*` calls take the read lock while `Shutdown` takes the write lock — preventing a use-after-free if Shutdown lands between an in-flight invoke and its FFI return. +- The Rust core uses `ArcSwap` for the registry — concurrent invokes read a stable snapshot; mutations clone-and-swap. This means an in-flight invoke sees the registry as it was when the invoke started, not as it is mid-call. +- `ContextTable` is NOT safe for concurrent use — it represents per-request state that is threaded sequentially through hook invocations. +- `BackgroundTasks` is safe to call `Wait()` or `Close()` from any goroutine, but only once. + +## 17. Gaps and Unimplemented Features + +The following features exist in the Python CPEX implementation but are not yet exposed in the Go API. These are tracked for future implementation: + +| Feature | Python Location | Status in Go | +|---|---|---| +| `invoke_hook_for_plugin(name, hook, payload)` | `manager.py` | Not implemented — no single-plugin invoke | +| `HookPayloadPolicy` (field-level write control) | `manager.py` / `hooks/policies.py` | Handled in Rust core via plugin capabilities, not configurable from Go | +| `TenantPluginManager` (per-tenant isolation) | `manager.py` | Not implemented — single global manager only (multi-tenant hosts can use one manager per tenant since Pass 9's shared runtime caps total threads) | +| Plugin introspection | `hooks/registry.py` | Partial — `HasHooksFor`, `PluginCount`, `PluginNames`, `IsInitialized` exposed; per-hook plugin lookup is not | +| Observability provider injection | `manager.py` | Not exposed — observability configured in Rust | +| Plugin conditions (runtime skip) | `manager.py` | Handled in Rust core via YAML config (`MatchContext` evaluated against extensions) | +| `OnError.DISABLE` runtime status query | `manager.py` | Not exposed (errors from disabled plugins surface in `result.Errors` though) | +| `reset()` (reinitialize without restart) | `manager.py` | Not implemented — shutdown and recreate | +| Programmatic capability gating | `extensions/tiers.py` | YAML-only — capabilities declared per-plugin in config (§9.3); no runtime API to override or rebind capabilities per-invoke | +| gRPC/Unix/MCP external plugin transports | `framework/external/` | Not yet in Rust core | +| Plugin loader with search paths | `loader/` | Rust uses factory registration instead | +| PDP (AuthZen/OPA) integration | `framework/pdp/` | Not yet in Rust core | +| Isolated (subprocess) plugins | `framework/isolated/` | Not yet in Rust core | +| `retry_delay_ms` in result | `models.py` | Not exposed in FFI result | + +## 18. Build & Test + +The repo ships a Makefile with the canonical commands. The raw `cargo` / `go` invocations are still listed below for environments without `make`. + +### 18.1 Make targets (recommended) + +| Target | What it does | +|---|---| +| `make rust-build` / `rust-build-release` | Build Rust workspace (debug / release) | +| `make rust-test` | Full Rust workspace tests | +| `make rust-test-ffi` | Only the cpex-ffi crate tests | +| `make rust-lint-check` | Read-only `cargo fmt --check` + `cargo clippy -- -D warnings` | +| `make rust-lint` (or `rust-lint-fix`) | Mutating: `cargo fmt` + `clippy --fix` | +| `make go-build` | Build the Go cpex package (auto-rebuilds cdylib first) | +| `make go-test` / `go-test-race` | Go tests (with optional race detector) | +| `make go-lint-check` | Read-only `gofmt -l` + `go vet` + `golangci-lint run` | +| `make go-lint` (or `go-lint-fix`) | Mutating: `gofmt -w` + `vet` + `golangci-lint run --fix` | +| `make examples-build` | Build all 4 examples — catches stale public-API usage | +| `make examples-run` | Build + run each example end-to-end | +| `make test-all` | `rust-test` + `go-test-race` (the canonical "everything") | +| `make ci` | `rust-lint-check` + `test-all` + `examples-build` (the CI gate) | + +`golangci-lint` is required for `go-lint*`; install with `brew install golangci-lint`. + +### 18.2 Raw commands + +```bash +# 1. Build the Rust FFI library +cargo build --release -p cpex-ffi + +# 2. Run Go tests (links against libcpex_ffi) +cd go/cpex && go test -count=1 -race ./... + +# 3. Run the demo (requires demo plugin library) +cd examples/go-demo/ffi && cargo build --release +cd examples/go-demo && go run . + +# 4. Run the CMF demo +cd examples/go-demo && go run ./cmd/cmf-demo +``` + +**Platform notes:** +- macOS: link with `-framework CoreFoundation -framework Security` +- Linux: link with `-lm -ldl -lpthread` +- The `#cgo LDFLAGS` directive in `ffi.go` points to `target/release/` + diff --git a/examples/go-demo/.gitignore b/examples/go-demo/.gitignore new file mode 100644 index 00000000..8123b755 --- /dev/null +++ b/examples/go-demo/.gitignore @@ -0,0 +1,3 @@ +# Built demo binaries +cpex-demo +cmf-demo diff --git a/examples/go-demo/README.md b/examples/go-demo/README.md new file mode 100644 index 00000000..cfc4016b --- /dev/null +++ b/examples/go-demo/README.md @@ -0,0 +1,349 @@ +# CPEX Go Demo + +Two runnable examples showing the full CPEX plugin pipeline from Go, with plugins written in Rust and loaded via YAML configuration. + +## Prerequisites + +- **Go 1.21+** +- **Rust toolchain** (stable, 1.75+) + +## Build + +```bash +# 1. Build the demo FFI library (includes core + demo plugins) +cd examples/go-demo/ffi +cargo build --release + +# 2. Build the Go demos +cd examples/go-demo +go build -o cpex-demo . +go build -o cmf-demo ./cmd/cmf-demo/ +``` + +## Demo 1: Generic Payload (`cpex-demo`) + +Uses `PayloadGeneric` (untyped `map[string]any`) with three plugins: + +| Plugin | Kind | Mode | What it does | +|--------|------|------|-------------| +| identity-checker | `builtin/identity` | sequential | Validates `user` field present | +| pii-guard | `builtin/pii` | sequential | Blocks PII-tagged tools without clearance | +| audit-logger | `builtin/audit` | fire_and_forget | Logs tool invocations | + +### Run + +```bash +cd examples/go-demo +./cpex-demo +``` + +### Expected output + +``` +=== CPEX Go Demo === + +Plugins loaded: 3 +Hooks: tool_pre_invoke=true tool_post_invoke=true + +=== Scenario 1: get_compensation (no PII clearance) === + Result: DENIED — PII clearance required for this operation [pii_access_denied] + +=== Scenario 2: get_compensation (with PII clearance) === + Result: ALLOWED + +=== Scenario 3: list_departments (non-PII tool) === + Result: ALLOWED + +=== Scenario 4: list_departments (no user identity) === + Result: DENIED — User identity is required [no_identity] +``` + +### Config + +See [`plugins.yaml`](plugins.yaml) for the full configuration including routing rules and policy groups. + +## Demo 2: CMF Payload (`cmf-demo`) + +Uses `PayloadCMFMessage` (typed CMF messages) with rich extensions and two plugins: + +| Plugin | Kind | Mode | What it does | +|--------|------|------|-------------| +| tool-policy | `builtin/cmf-tool-policy` | sequential | Checks tool permissions against security labels | +| header-injector | `builtin/cmf-header-injector` | sequential | Injects response headers via capability-gated write | + +### Run + +```bash +cd examples/go-demo +./cmf-demo +``` + +### Expected output + +``` +=== CPEX CMF Demo === + +Plugins loaded: 2 + +=== Scenario 1: get_compensation tool call (no PII label) === + Result: DENIED — Tool 'get_compensation' is PII-tagged but security context lacks PII label + +=== Scenario 2: get_compensation tool call (with PII label) === + Result: ALLOWED + Modified response headers: + X-Tool-Name: get_compensation + X-Tool-Status: success + X-CPEX-Processed: true + +=== Scenario 3: tool result post-invoke (header injection) === + Result: ALLOWED + Modified response headers: + X-Tool-Name: get_compensation + ... +``` + +### Config + +See [`cmf_plugins.yaml`](cmf_plugins.yaml) for capabilities and routing. + +## Architecture + +``` +Go (main.go) + │ + │ cpex.NewPluginManagerDefault() + │ cpex.RegisterFactories(callback) ← one raw C call + │ cpex.LoadConfig(yaml) + │ cpex.Initialize() + │ cpex.InvokeByName(hook, payload, extensions, ...) + │ + ▼ +Go SDK (go/cpex/) + │ MessagePack serialize payload + extensions + │ + ▼ +cgo FFI (libcpex_demo_ffi.a) + │ cpex_invoke() → Rust executor + │ + ▼ +Rust Plugins (examples/go-demo/ffi/src/) + │ Plugin::handle() → PluginResult + │ + ▼ +cgo FFI + │ MessagePack serialize result + modified extensions + │ + ▼ +Go SDK + │ PipelineResult { IsDenied(), Violation, ModifiedExtensions } + │ + ▼ +Go (main.go) +``` + +## Demo Crate Structure + +``` +examples/go-demo/ + main.go — generic payload demo + plugins.yaml — config for generic demo + cmf_plugins.yaml — config for CMF demo + go.mod — Go module (depends on go/cpex) + cmd/ + cmf-demo/ + main.go — CMF payload demo + ffi/ + Cargo.toml — Rust crate: cpex-demo-ffi + src/ + lib.rs — C FFI: cpex_demo_register_factories() + demo_plugins.rs — 3 generic plugins (identity, PII, audit) + cmf_plugins.rs — 2 CMF plugins (tool-policy, header-injector) +``` + +The `cpex-demo-ffi` crate builds a staticlib that includes both the core `cpex-ffi` symbols and the demo plugin factories. Go links only this one library. + +## How Factory Registration Works + +The Go SDK's `PluginManager` wraps the Rust manager. Plugin factories are Rust code, so registration happens through a callback: + +```go +mgr.RegisterFactories(func(handle unsafe.Pointer) error { + // handle is the raw Rust manager pointer + // Call your crate's C registration function + C.cpex_demo_register_factories(handle) + return nil +}) +``` + +This keeps the Go SDK generic — it doesn't know about specific factories. Each Rust crate exports its own `register_*_factories()` function. + +--- + +# Adding New Payload Types and Hooks + +This section covers how to extend the system with new payload types for Go-to-Rust plugin pipelines. + +## Overview + +The CPEX payload type registry maps a `uint8` discriminator to a concrete Rust type for efficient deserialization across the FFI boundary. Currently: + +| ID | Constant | Rust Type | Go Type | +|----|----------|-----------|---------| +| 0 | `PAYLOAD_GENERIC` | `GenericPayload` | `map[string]any` | +| 1 | `PAYLOAD_CMF_MESSAGE` | `MessagePayload` | `MessagePayload` | + +## Step-by-Step: Adding a New Payload Type + +### 1. Define the Rust payload type + +In your Rust crate (e.g., `cpex-core` or a separate crate): + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MyPayload { + pub field_a: String, + pub field_b: i64, +} +cpex_core::impl_plugin_payload!(MyPayload); +``` + +### 2. Register the payload type in the FFI crate + +In `crates/cpex-ffi/src/lib.rs`: + +```rust +// Add the constant +pub const PAYLOAD_MY_TYPE: u8 = 2; + +// Add a deserialize arm +fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result<...> { + match payload_type { + // ... existing arms ... + PAYLOAD_MY_TYPE => { + let v: MyPayload = rmp_serde::from_slice(bytes)?; + Ok(Box::new(v)) + } + _ => Err(...) + } +} + +// Add a serialize arm +fn serialize_payload(payload: &dyn PluginPayload) -> Option<(u8, Vec)> { + // ... existing checks ... + if let Some(mp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(mp).ok().map(|b| (PAYLOAD_MY_TYPE, b)); + } + // ... +} +``` + +### 3. Define the Go struct + +In `go/cpex/types.go` (or a new file): + +```go +const PayloadMyType uint8 = 2 + +type MyPayload struct { + FieldA string `msgpack:"field_a"` + FieldB int64 `msgpack:"field_b"` +} +``` + +### 4. Use it + +```go +result, ct, bg, err := mgr.InvokeByName( + "my_hook", + cpex.PayloadMyType, + MyPayload{FieldA: "hello", FieldB: 42}, + ext, + nil, +) + +// Deserialize modified payload from result +modified, err := cpex.DeserializePayload[MyPayload](result) +``` + +### Total: 5 touch points + +1. Rust struct + `impl_plugin_payload!` +2. FFI constant +3. FFI `deserialize_payload` match arm +4. FFI `serialize_payload` downcast +5. Go struct with msgpack tags + +## Step-by-Step: Adding a New Hook Type + +Hooks define what payload goes in and what comes out. For Go callers, hooks are identified by string name (e.g., `"tool_pre_invoke"`). + +### 1. Define the hook type in Rust + +```rust +pub struct MyHook; +impl HookTypeDef for MyHook { + type Payload = MyPayload; + type Result = PluginResult; + const NAME: &'static str = "my_hook"; +} +``` + +### 2. Write a plugin that handles it + +```rust +impl HookHandler for MyPlugin { + fn handle( + &self, + payload: &MyPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // ... your logic ... + PluginResult::allow() + } +} +``` + +### 3. Create a factory and register it + +```rust +struct MyPluginFactory; +impl PluginFactory for MyPluginFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(MyPlugin { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("my_hook", Arc::new(TypedHandlerAdapter::::new(plugin))), + ], + }) + } +} +``` + +### 4. Register in your FFI crate + +```rust +pub fn register_my_factories(manager: &mut PluginManager) { + manager.register_factory("my-plugin-kind", Box::new(MyPluginFactory)); +} +``` + +### 5. Add to YAML config + +```yaml +plugins: + - name: my-plugin + kind: my-plugin-kind + hooks: [my_hook] + mode: sequential + priority: 10 +``` + +### 6. Invoke from Go + +```go +result, ct, bg, err := mgr.InvokeByName("my_hook", cpex.PayloadMyType, payload, ext, nil) +``` + +The Go side doesn't need to know about the Rust hook type — it just uses the string name and the payload type constant. diff --git a/examples/go-demo/cmf_plugins.yaml b/examples/go-demo/cmf_plugins.yaml new file mode 100644 index 00000000..53664a36 --- /dev/null +++ b/examples/go-demo/cmf_plugins.yaml @@ -0,0 +1,60 @@ +# Location: ./examples/go-demo/cmf_plugins.yaml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CMF Demo — plugin configuration +# +# Two CMF plugins demonstrating typed message processing: +# - Tool policy: checks tool permissions against security labels +# - Header injector: adds response headers after processing +# +# Uses cmf.tool_pre_invoke and cmf.tool_post_invoke hooks with +# MessagePayload (typed CMF messages, not generic maps). + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + all: + plugins: [tool-policy] + pii: + plugins: [tool-policy] + +plugins: + - name: tool-policy + kind: builtin/cmf-tool-policy + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: + - read_labels + - read_subject + + - name: header-injector + kind: builtin/cmf-header-injector + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 50 + on_error: ignore + capabilities: + - read_headers + - write_headers + +routes: + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - header-injector + + - tool: list_departments + plugins: + - header-injector + + - tool: "*" + plugins: + - header-injector diff --git a/examples/go-demo/ffi/Cargo.toml b/examples/go-demo/ffi/Cargo.toml new file mode 100644 index 00000000..1cd1e60c --- /dev/null +++ b/examples/go-demo/ffi/Cargo.toml @@ -0,0 +1,27 @@ +# Location: ./examples/go-demo/ffi/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX Demo FFI — demo plugins for Go example. +# +# Builds a staticlib that includes cpex-ffi symbols transitively. +# Go links only this library to get both the core FFI surface and +# the demo plugin factories. + +[package] +name = "cpex-demo-ffi" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[lib] +crate-type = ["staticlib", "cdylib"] + +[dependencies] +cpex-core = { path = "../../../crates/cpex-core" } +cpex-ffi = { path = "../../../crates/cpex-ffi" } +async-trait = "0.1" +serde_json = "1" +tracing = "0.1" diff --git a/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs new file mode 100644 index 00000000..f59d9e84 --- /dev/null +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -0,0 +1,274 @@ +// Location: ./examples/go-demo/ffi/src/cmf_plugins.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF demo plugins — operate on MessagePayload (typed CMF messages). +// +// Two plugins demonstrating typed message inspection and +// capability-gated extension modification: +// +// - ToolPolicyPlugin: extracts tool calls from the CMF message, +// checks permissions against meta tags and security labels. +// PII-tagged tools require a "PII" label in the security +// extension; admin-tagged tools require an "admin" role. +// +// - HeaderInjectorPlugin: inspects tool calls/results and injects +// response headers (X-Tool-Name, X-Tool-Status, X-CPEX-Processed) +// using the capability-gated Guarded write pattern. +// Requires "write_headers" capability in the plugin config. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::{ContentPart, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +// --------------------------------------------------------------------------- +// CMF Hook Type +// --------------------------------------------------------------------------- + +/// Hook type for CMF message processing. The hook *name* varies +/// (cmf.tool_pre_invoke, cmf.tool_post_invoke, etc.) but the payload +/// is always MessagePayload. +pub struct CmfHook; + +impl HookTypeDef for CmfHook { + type Payload = MessagePayload; + type Result = PluginResult; + const NAME: &'static str = "cmf"; +} + +// --------------------------------------------------------------------------- +// Tool Policy Plugin +// --------------------------------------------------------------------------- + +/// Checks tool call permissions against security labels and meta tags. +/// +/// Policy rules: +/// - Tools tagged "pii" require security label "PII" in extensions +/// - Tools tagged "admin" require subject role "admin" +/// - All tool calls are logged with their arguments +struct ToolPolicyPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for ToolPolicyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for ToolPolicyPlugin { + fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Extract tool calls from the CMF message + let tool_calls: Vec<_> = payload + .message + .content + .iter() + .filter_map(|cp| match cp { + ContentPart::ToolCall { content } => Some(content), + _ => None, + }) + .collect(); + + if tool_calls.is_empty() { + return PluginResult::allow(); + } + + // Check meta tags for PII requirement + let has_pii_tag = extensions + .meta + .as_ref() + .map(|m| m.tags.iter().any(|t| t == "pii")) + .unwrap_or(false); + + // Check security labels + let has_pii_label = extensions + .security + .as_ref() + .map(|s| s.labels.contains(&"PII".to_string())) + .unwrap_or(false); + + // If PII tagged but no PII label in security context — deny + if has_pii_tag && !has_pii_label { + let tool_name = tool_calls + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + + tracing::warn!( + "[tool-policy] DENIED: tool '{}' requires PII label but caller lacks it", + tool_name + ); + return PluginResult::deny(PluginViolation::new( + "pii_label_required", + format!( + "Tool '{}' is PII-tagged but security context lacks PII label", + tool_name + ), + )); + } + + // Check admin requirement + let has_admin_tag = extensions + .meta + .as_ref() + .map(|m| m.tags.iter().any(|t| t == "admin")) + .unwrap_or(false); + + if has_admin_tag { + let has_admin_role = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .map(|subj| subj.roles.iter().any(|r| r == "admin")) + .unwrap_or(false); + + if !has_admin_role { + return PluginResult::deny(PluginViolation::new( + "admin_required", + "This tool requires admin role", + )); + } + } + + for tc in &tool_calls { + tracing::info!( + "[tool-policy] OK: tool '{}' (call_id={}) authorized", + tc.name, + tc.tool_call_id, + ); + } + + PluginResult::allow() + } +} + +pub struct ToolPolicyFactory; + +impl PluginFactory for ToolPolicyFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(ToolPolicyPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------------- +// Header Injector Plugin +// --------------------------------------------------------------------------- + +/// Adds response headers after tool execution. +/// +/// Inspects the CMF message (tool results) and adds: +/// - X-Tool-Name: name of the tool that ran +/// - X-Tool-Status: "success" or "error" +/// - X-CPEX-Processed: "true" +struct HeaderInjectorPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for HeaderInjectorPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for HeaderInjectorPlugin { + fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Look for tool results or tool calls + let tool_name = payload.message.content.iter().find_map(|cp| match cp { + ContentPart::ToolResult { content } => Some(content.tool_name.as_str()), + ContentPart::ToolCall { content } => Some(content.name.as_str()), + _ => None, + }); + + let is_error = payload.message.content.iter().any(|cp| { + matches!( + cp, + ContentPart::ToolResult { content } if content.is_error + ) + }); + + if let Some(name) = tool_name { + // COW copy — clones mutable slots, propagates write tokens + let mut owned = extensions.cow_copy(); + + // Write to HTTP extension — requires write token from capability + if let Some(ref token) = owned.http_write_token { + if let Some(http) = owned.http.as_mut() { + let h = http.write(token); + h.set_response_header("X-Tool-Name", name); + h.set_response_header( + "X-Tool-Status", + if is_error { "error" } else { "success" }, + ); + h.set_response_header("X-CPEX-Processed", "true"); + } + } + + return PluginResult::modify_extensions(owned); + } + + PluginResult::allow() + } +} + +pub struct HeaderInjectorFactory; + +impl PluginFactory for HeaderInjectorFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(HeaderInjectorPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +/// Register CMF demo plugin factories on a manager. +pub fn register_cmf_factories(manager: &mut cpex_core::manager::PluginManager) { + manager.register_factory("builtin/cmf-tool-policy", Box::new(ToolPolicyFactory)); + manager.register_factory( + "builtin/cmf-header-injector", + Box::new(HeaderInjectorFactory), + ); +} diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs new file mode 100644 index 00000000..84351929 --- /dev/null +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -0,0 +1,275 @@ +// Location: ./examples/go-demo/ffi/src/demo_plugins.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Generic demo plugins for the Go example. +// +// Three plugins that operate on GenericPayload (serde_json::Value), +// demonstrating identity validation, PII policy enforcement, and +// audit logging through the CPEX plugin pipeline: +// +// - IdentityChecker: validates that a "user" field is present in +// the payload or a subject ID exists in security extensions +// - PiiGuard: blocks access to PII-tagged tools unless the payload +// contains a "pii_clearance" flag +// - AuditLogger: logs tool invocations with entity type, tool name, +// and user (fire-and-forget mode) + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use cpex_ffi::GenericPayload; + +// --------------------------------------------------------------------------- +// Generic Hook Type +// --------------------------------------------------------------------------- + +/// A hook type for FFI callers that send untyped map payloads. +/// The hook *name* varies at registration time (tool_pre_invoke, etc.) +/// but the payload type is always GenericPayload. +pub struct GenericHook; + +impl HookTypeDef for GenericHook { + type Payload = GenericPayload; + type Result = PluginResult; + const NAME: &'static str = "generic"; +} + +// --------------------------------------------------------------------------- +// Identity Checker +// --------------------------------------------------------------------------- + +struct IdentityChecker { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for IdentityChecker { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for IdentityChecker { + fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let user = payload.value.get("user").and_then(|v| v.as_str()); + + let subject_id = extensions + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.as_deref()); + + match user.or(subject_id) { + Some(u) if !u.is_empty() => { + tracing::info!("[identity-checker] OK: user '{}' identified", u); + PluginResult::allow() + } + _ => { + tracing::warn!("[identity-checker] DENIED: no user identity"); + PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )) + } + } + } +} + +pub struct IdentityCheckerFactory; + +impl PluginFactory for IdentityCheckerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(IdentityChecker { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +// --------------------------------------------------------------------------- +// PII Guard +// --------------------------------------------------------------------------- + +struct PiiGuard { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for PiiGuard { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for PiiGuard { + fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let has_pii_tag = extensions + .meta + .as_ref() + .map(|m| m.tags.iter().any(|t| t == "pii")) + .unwrap_or(false); + + let has_pii_label = extensions + .security + .as_ref() + .map(|s| s.labels.contains(&"PII".to_string())) + .unwrap_or(false); + + if !has_pii_tag && !has_pii_label { + return PluginResult::allow(); + } + + let has_clearance = payload + .value + .get("pii_clearance") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if has_clearance { + tracing::info!("[pii-guard] OK: PII clearance verified"); + PluginResult::allow() + } else { + let tool_name = payload + .value + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + tracing::warn!( + "[pii-guard] DENIED: PII clearance required for '{}'", + tool_name + ); + PluginResult::deny(PluginViolation::new( + "pii_access_denied", + "PII clearance required for this operation", + )) + } + } +} + +pub struct PiiGuardFactory; + +impl PluginFactory for PiiGuardFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(PiiGuard { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------------- +// Audit Logger +// --------------------------------------------------------------------------- + +struct AuditLogger { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AuditLogger { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AuditLogger { + fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let tool_name = payload + .value + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let user = payload + .value + .get("user") + .and_then(|v| v.as_str()) + .unwrap_or("anonymous"); + let entity_type = extensions + .meta + .as_ref() + .and_then(|m| m.entity_type.as_deref()) + .unwrap_or("unknown"); + + tracing::info!( + "[audit-logger] LOG: entity_type={} tool={} user={}", + entity_type, + tool_name, + user, + ); + PluginResult::allow() + } +} + +pub struct AuditLoggerFactory; + +impl PluginFactory for AuditLoggerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AuditLogger { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin.clone())), + ), + ( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + ), + ], + }) + } +} + +/// Register all demo plugin factories on a manager. +pub fn register_demo_factories(manager: &mut cpex_core::manager::PluginManager) { + manager.register_factory("builtin/identity", Box::new(IdentityCheckerFactory)); + manager.register_factory("builtin/pii", Box::new(PiiGuardFactory)); + manager.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); +} diff --git a/examples/go-demo/ffi/src/lib.rs b/examples/go-demo/ffi/src/lib.rs new file mode 100644 index 00000000..8f756f3a --- /dev/null +++ b/examples/go-demo/ffi/src/lib.rs @@ -0,0 +1,56 @@ +// Location: ./examples/go-demo/ffi/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Demo FFI — re-exports cpex-ffi and adds demo plugin factories. +// +// This crate builds a staticlib that includes all cpex-ffi symbols +// transitively. Go links only this library — no need to link +// libcpex_ffi separately. +// +// Exports one C function: `cpex_demo_register_factories()` which +// registers both generic and CMF demo plugin factories: +// +// Generic (GenericPayload): +// - `builtin/identity` — identity checker +// - `builtin/pii` — PII guard +// - `builtin/audit` — audit logger +// +// CMF (MessagePayload): +// - `builtin/cmf-tool-policy` — tool permission checking +// - `builtin/cmf-header-injector` — response header injection + +mod cmf_plugins; +mod demo_plugins; + +// Force the linker to include all cpex-ffi symbols in our staticlib. +// Without this, the extern "C" functions from cpex-ffi would be +// stripped as "unused" since we don't call them from Rust. +extern crate cpex_ffi; + +use std::os::raw::c_int; + +/// Register demo plugin factories on the manager. +/// +/// Must be called after `cpex_manager_new_default()` and before +/// `cpex_load_config()`. Registers: +/// - `builtin/identity` — identity checker +/// - `builtin/pii` — PII guard +/// - `builtin/audit` — audit logger +/// +/// # Safety +/// `mgr` must be a valid handle from `cpex_manager_new_default`. +#[no_mangle] +pub unsafe extern "C" fn cpex_demo_register_factories( + mgr: *mut cpex_ffi::CpexManagerInner, +) -> c_int { + let inner = match mgr.as_mut() { + Some(m) => m, + None => return -1, + }; + + demo_plugins::register_demo_factories(&mut inner.manager); + cmf_plugins::register_cmf_factories(&mut inner.manager); + 0 +} diff --git a/examples/go-demo/go.mod b/examples/go-demo/go.mod new file mode 100644 index 00000000..4e5bff08 --- /dev/null +++ b/examples/go-demo/go.mod @@ -0,0 +1,12 @@ +module github.com/contextforge-org/contextforge-plugins-framework/examples/go-demo + +go 1.25.4 + +require github.com/contextforge-org/contextforge-plugins-framework/go/cpex v0.0.0 + +require ( + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect +) + +replace github.com/contextforge-org/contextforge-plugins-framework/go/cpex => ../../go/cpex diff --git a/examples/go-demo/go.sum b/examples/go-demo/go.sum new file mode 100644 index 00000000..fd15c1b8 --- /dev/null +++ b/examples/go-demo/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/go-demo/main.go b/examples/go-demo/main.go new file mode 100644 index 00000000..33aecc16 --- /dev/null +++ b/examples/go-demo/main.go @@ -0,0 +1,242 @@ +// Location: ./examples/go-demo/main.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Go Demo — generic payload example. +// +// Demonstrates the full CPEX plugin pipeline from Go using +// GenericPayload (untyped map payloads): +// +// 1. Create a PluginManager via the Go SDK +// 2. Register demo plugin factories (identity, PII, audit) +// 3. Load YAML config with routing rules and policy groups +// 4. Invoke hooks with MetaExtension for route resolution +// 5. Inspect results (allow/deny, violations) +// 6. Thread ContextTable between pre-invoke and post-invoke +// +// Build & run: +// +// cd examples/go-demo/ffi && cargo build --release +// cd examples/go-demo && go run main.go + +package main + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_demo_ffi -lm -ldl -lpthread -framework CoreFoundation -framework Security +#include + +int cpex_demo_register_factories(void* mgr); +*/ +import "C" + +import ( + "fmt" + "os" + "unsafe" + + cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +) + +func main() { + fmt.Println("=== CPEX Go Demo ===") + fmt.Println() + + // --- Create manager --- + mgr, err := cpex.NewPluginManagerDefault() + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + defer mgr.Shutdown() + + // --- Register demo factories via callback --- + err = mgr.RegisterFactories(func(handle unsafe.Pointer) error { + rc := C.cpex_demo_register_factories(handle) + if rc != 0 { + return fmt.Errorf("cpex_demo_register_factories returned %d", rc) + } + return nil + }) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + // --- Load YAML config --- + yaml, err := os.ReadFile("plugins.yaml") + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + if err := mgr.LoadConfig(string(yaml)); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + // --- Initialize --- + if err := mgr.Initialize(); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Plugins loaded: %d\n", mgr.PluginCount()) + fmt.Printf("Hooks: tool_pre_invoke=%v tool_post_invoke=%v\n\n", + mgr.HasHooksFor("tool_pre_invoke"), + mgr.HasHooksFor("tool_post_invoke"), + ) + + // ----------------------------------------------------------------------- + // Scenario 1: PII tool WITHOUT clearance — should be DENIED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 1: get_compensation (no PII clearance) ===") + fmt.Println() + + result, ct, bg, err := mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "get_compensation", + "user": "alice", + "arguments": "employee_id=42", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + ct.Close() + + // ----------------------------------------------------------------------- + // Scenario 2: PII tool WITH clearance — should be ALLOWED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 2: get_compensation (with PII clearance) ===") + fmt.Println() + + result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "get_compensation", + "user": "alice", + "arguments": "employee_id=42", + "pii_clearance": true, + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + + // Thread context table into post-invoke + fmt.Println(" --- post-invoke for get_compensation ---") + fmt.Println() + + result2, ct2, bg2, err := mgr.InvokeByName("tool_post_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "get_compensation", + "user": "alice", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + }, + ct, // thread context table from pre-invoke + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result2) + bg2.Close() + ct2.Close() + + // ----------------------------------------------------------------------- + // Scenario 3: Non-PII tool — should be ALLOWED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 3: list_departments (non-PII tool) ===") + fmt.Println() + + result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "list_departments", + "user": "bob", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "list_departments", + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + ct.Close() + + // ----------------------------------------------------------------------- + // Scenario 4: No user identity — should be DENIED by identity-checker + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 4: list_departments (no user identity) ===") + fmt.Println() + + result, ct, bg, err = mgr.InvokeByName("tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{ + "tool_name": "list_departments", + }, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "list_departments", + }, + }, + nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + printResult(result) + bg.Close() + ct.Close() + + fmt.Println("=== Demo complete ===") +} + +func printResult(result *cpex.PipelineResult) { + if !result.IsDenied() { + fmt.Printf(" Result: ALLOWED\n\n") + } else { + v := result.Violation + fmt.Printf(" Result: DENIED — %s [%s]\n\n", v.Reason, v.Code) + } +} diff --git a/examples/go-demo/plugins.yaml b/examples/go-demo/plugins.yaml new file mode 100644 index 00000000..f7975cbb --- /dev/null +++ b/examples/go-demo/plugins.yaml @@ -0,0 +1,59 @@ +# Location: ./examples/go-demo/plugins.yaml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX Go Demo — plugin configuration +# +# Three plugins with routing rules that demonstrate: +# - Identity validation on every invocation (global policy) +# - PII guarding on tagged tools (tag-based policy group) +# - Audit logging on all tools (fire-and-forget) + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + all: + plugins: [identity-checker] + pii: + plugins: [pii-guard] + +plugins: + - name: identity-checker + kind: builtin/identity + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + +routes: + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + - tool: list_departments + plugins: + - audit-logger + + - tool: "*" + plugins: + - audit-logger diff --git a/go/cpex/README.md b/go/cpex/README.md new file mode 100644 index 00000000..220eef68 --- /dev/null +++ b/go/cpex/README.md @@ -0,0 +1,367 @@ +# CPEX Go SDK + +Go bindings for the CPEX plugin runtime. Wraps the Rust core via cgo — all plugin execution happens in Rust, called from Go through MessagePack-serialized payloads and opaque handles. + +## Prerequisites + +- **Go 1.21+** +- **Rust toolchain** (stable, 1.75+) +- **Built Rust library**: the Go SDK links against `libcpex_ffi.a` + +```bash +# From the repository root +cargo build --release -p cpex-ffi +``` + +## Package Structure + +``` +go/cpex/ + ffi.go — cgo declarations (C function signatures) + manager.go — PluginManager, ContextTable, BackgroundTasks + types.go — Extensions, PipelineResult, payload constants + cmf.go — CMF Message, ContentPart, domain objects + manager_test.go — tests (require built libcpex_ffi) +``` + +## Quick Start + +```go +import cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" + +// 1. Create a manager +mgr, err := cpex.NewPluginManagerDefault() +defer mgr.Shutdown() + +// 2. Register plugin factories (Rust-side, via callback) +mgr.RegisterFactories(func(handle unsafe.Pointer) error { + C.my_register_factories(handle) + return nil +}) + +// 3. Load YAML config +mgr.LoadConfig(yamlString) + +// 4. Initialize plugins +mgr.Initialize() + +// 5. Invoke a hook +result, ct, bg, err := mgr.InvokeByName( + "tool_pre_invoke", + cpex.PayloadGeneric, + map[string]any{"tool_name": "get_compensation", "user": "alice"}, + &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii"}, + }, + }, + nil, // context table (nil for first call) +) +defer ct.Close() +defer bg.Close() + +if result.IsDenied() { + fmt.Printf("Denied: %s\n", result.Violation.Reason) +} +``` + +## Lifecycle + +``` +NewPluginManagerDefault() + → RegisterFactories(fn) // register Rust plugin factories + → LoadConfig(yaml) // parse YAML, instantiate plugins + → Initialize() // call plugin.initialize() on all + → InvokeByName(...) // invoke hooks, get results + → Shutdown() // call plugin.shutdown(), free resources +``` + +## Payload Types + +| Constant | Value | Description | +|----------------------|-------|----------------------------------| +| `PayloadGeneric` | 0 | Generic map payload (`map[string]any`) | +| `PayloadCMFMessage` | 1 | Typed CMF `MessagePayload` | + +Use `PayloadGeneric` for simple key-value payloads. Use `PayloadCMFMessage` when sending structured CMF messages with typed content parts (tool calls, resources, media, etc.). + +## CMF Content Types + +The `ContentPart` tagged union supports all 12 content types: + +| Type | Constructor | Content Field | +|------|-------------|---------------| +| `text` | `NewTextPart("hello")` | `Text` | +| `thinking` | `NewThinkingPart("...")` | `Text` | +| `tool_call` | `NewToolCallPart(tc)` | `ToolCallContent` | +| `tool_result` | `NewToolResultPart(tr)` | `ToolResultContent` | +| `resource` | `NewResourcePart(r)` | `ResourceContent` | +| `resource_ref` | `NewResourceRefPart(r)` | `ResourceRefContent` | +| `prompt_request` | `NewPromptRequestPart(pr)` | `PromptRequestContent` | +| `prompt_result` | `NewPromptResultPart(pr)` | `PromptResultContent` | +| `image` | `NewImagePart(img)` | `ImageContent` | +| `video` | `NewVideoPart(vid)` | `VideoContent` | +| `audio` | `NewAudioPart(aud)` | `AudioContent` | +| `document` | `NewDocumentPart(doc)` | `DocumentContent` | + +## Extensions + +Extensions are passed separately from the payload. Each extension type maps to a Rust extension in `crates/cpex-core/src/extensions/`: + +- `MetaExtension` — entity identification for route resolution +- `SecurityExtension` — labels, classification, subject identity +- `HttpExtension` — request/response headers +- `DelegationExtension` — token delegation chain +- `AgentExtension` — agent session and conversation context +- `RequestExtension` — environment, tracing, request ID +- `MCPExtension` — MCP tool/resource/prompt metadata +- `CompletionExtension` — LLM completion stats +- `ProvenanceExtension` — message origin and threading +- `LLMExtension` — model identity and capabilities +- `FrameworkExtension` — agentic framework context + +## Context Threading + +Pass the `ContextTable` from one invocation to the next to preserve per-plugin state across hooks: + +```go +result1, ct1, bg1, _ := mgr.InvokeByName("tool_pre_invoke", ...) +bg1.Close() + +// Thread context table into post-invoke +result2, ct2, bg2, _ := mgr.InvokeByName("tool_post_invoke", ..., ct1) +bg2.Close() +ct2.Close() +``` + +## Writing Plugins (Rust) for Go Callers + +Plugins are written in Rust and compiled into a separate FFI crate that the Go program links. This keeps the core `cpex-ffi` library clean while allowing each project to bring its own plugins. + +### 1. Create a Rust FFI crate + +``` +my-project/ + plugins-ffi/ + Cargo.toml + src/ + lib.rs + my_plugin.rs +``` + +**`Cargo.toml`**: + +```toml +[package] +name = "my-plugins-ffi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib", "cdylib"] + +[dependencies] +cpex-core = { path = "path/to/crates/cpex-core" } +cpex-ffi = { path = "path/to/crates/cpex-ffi" } +async-trait = "0.1" +tracing = "0.1" +``` + +### 2. Define your hook type and plugin + +**`src/my_plugin.rs`**: + +```rust +use std::sync::Arc; +use async_trait::async_trait; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_ffi::GenericPayload; + +// Hook type — the NAME can be any string; Go callers use this name +pub struct MyHook; +impl HookTypeDef for MyHook { + type Payload = GenericPayload; + type Result = PluginResult; + const NAME: &'static str = "my_hook"; +} + +// Plugin implementation +struct RateLimiter { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for RateLimiter { + fn config(&self) -> &PluginConfig { &self.cfg } +} + +impl HookHandler for RateLimiter { + fn handle( + &self, + payload: &GenericPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Your plugin logic here + PluginResult::allow() + } +} + +// Factory — creates plugin instances from YAML config +pub struct RateLimiterFactory; +impl PluginFactory for RateLimiterFactory { + fn create(&self, config: &PluginConfig) -> Result { + let plugin = Arc::new(RateLimiter { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![ + ("my_hook", Arc::new( + TypedHandlerAdapter::::new(plugin), + )), + ], + }) + } +} + +pub fn register_factories(manager: &mut cpex_core::manager::PluginManager) { + manager.register_factory("my/rate-limiter", Box::new(RateLimiterFactory)); +} +``` + +### 3. Export the C registration function + +**`src/lib.rs`**: + +```rust +mod my_plugin; + +// Include cpex-ffi symbols in this staticlib +extern crate cpex_ffi; + +use std::os::raw::c_int; + +#[no_mangle] +pub unsafe extern "C" fn my_register_factories( + mgr: *mut cpex_ffi::CpexManagerInner, +) -> c_int { + let inner = match mgr.as_mut() { + Some(m) => m, + None => return -1, + }; + my_plugin::register_factories(&mut inner.manager); + 0 +} +``` + +### 4. Call from Go + +```go +/* +#cgo LDFLAGS: -L/path/to/target/release -lmy_plugins_ffi -lm -ldl -lpthread +int my_register_factories(void* mgr); +*/ +import "C" + +mgr, _ := cpex.NewPluginManagerDefault() + +mgr.RegisterFactories(func(handle unsafe.Pointer) error { + if C.my_register_factories(handle) != 0 { + return fmt.Errorf("factory registration failed") + } + return nil +}) + +mgr.LoadConfig(yaml) // YAML references kind: "my/rate-limiter" +mgr.Initialize() + +result, ct, bg, _ := mgr.InvokeByName("my_hook", cpex.PayloadGeneric, payload, ext, nil) +``` + +### Key points + +- `extern crate cpex_ffi;` in your `lib.rs` ensures all core FFI symbols are included in your staticlib — Go links only your library +- The `CpexManagerInner` type from `cpex_ffi` gives you access to the `manager` field for factory registration +- Your C function signature is `int my_register_factories(void* mgr)` — Go passes the SDK's internal handle via the `RegisterFactories` callback +- The YAML `kind` field must match what you pass to `register_factory()` + +## Adding a New Payload Type + +The payload type registry maps a `uint8` discriminator to a Rust type for FFI deserialization. To add a new one: + +### Rust side (3 files) + +**1. Define the type** (in `cpex-core` or your own crate): + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MyPayload { + pub field_a: String, + pub field_b: i64, +} +cpex_core::impl_plugin_payload!(MyPayload); +``` + +**2. Register in FFI** (`crates/cpex-ffi/src/lib.rs`): + +```rust +pub const PAYLOAD_MY_TYPE: u8 = 2; + +// In deserialize_payload(): +PAYLOAD_MY_TYPE => { + let v: MyPayload = rmp_serde::from_slice(bytes)?; + Ok(Box::new(v)) +} + +// In serialize_payload(): +if let Some(mp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(mp).ok().map(|b| (PAYLOAD_MY_TYPE, b)); +} +``` + +### Go side (1 file) + +**3. Define the Go struct** (`go/cpex/types.go` or a new file): + +```go +const PayloadMyType uint8 = 2 + +type MyPayload struct { + FieldA string `msgpack:"field_a"` + FieldB int64 `msgpack:"field_b"` +} +``` + +### Use it + +```go +result, ct, bg, _ := mgr.InvokeByName("my_hook", cpex.PayloadMyType, payload, ext, nil) + +// Deserialize modified payload from result +modified, _ := cpex.DeserializePayload[MyPayload](result) +``` + +**Total: 5 touch points** — Rust struct, FFI constant, deserialize arm, serialize arm, Go struct. No framework registration or config changes needed. + +## Tests + +```bash +# Build the Rust library first +cargo build --release -p cpex-ffi + +# Run Go tests +cd go/cpex && go test -v ./... +``` + +## See Also + +- [Go Demo Examples](../../examples/go-demo/README.md) — runnable demos with YAML configs +- [Rust Core README](../../crates/README.md) — core runtime documentation +- [Rust Examples](../../crates/cpex-core/examples/README.md) — native Rust examples diff --git a/go/cpex/cmf.go b/go/cpex/cmf.go new file mode 100644 index 00000000..cfe82ead --- /dev/null +++ b/go/cpex/cmf.go @@ -0,0 +1,390 @@ +// Location: ./go/cpex/cmf.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CMF (ContextForge Message Format) types for Go. +// +// Mirrors the Rust types in crates/cpex-core/src/cmf/. The Message +// struct carries typed content parts (text, tool calls, resources, +// media, etc.) without extensions — those are passed separately. +// +// ContentPart is a tagged union discriminated by the "content_type" +// field. Custom msgpack Encoder/Decoder methods produce the same +// wire format as Rust's #[serde(tag = "content_type")] enum. + +package cpex + +import "github.com/vmihailenco/msgpack/v5" + +// --------------------------------------------------------------------------- +// CMF Message Types +// --------------------------------------------------------------------------- + +// MessagePayload wraps a Message for FFI transport. +// Matches Rust's cpex_core::cmf::MessagePayload. +type MessagePayload struct { + Message Message `msgpack:"message"` +} + +// Message is the ContextForge Message Format (CMF) message. +// No extensions — those are passed separately to the plugin pipeline. +type Message struct { + SchemaVersion string `msgpack:"schema_version"` + Role string `msgpack:"role"` + Content []ContentPart `msgpack:"content"` + Channel string `msgpack:"channel,omitempty"` +} + +// NewMessage creates a Message with the default schema version. +func NewMessage(role string, content ...ContentPart) Message { + return Message{ + SchemaVersion: "2.0", + Role: role, + Content: content, + } +} + +// --------------------------------------------------------------------------- +// Content Parts — tagged union via content_type discriminator +// --------------------------------------------------------------------------- + +// ContentPart represents one element in a Message's content list. +// Uses custom msgpack marshaling to produce the tagged-union wire format: +// +// {"content_type": "text", "text": "hello"} +// {"content_type": "tool_call", "content": {...}} +// +// The ContentType field determines which content field is populated. +// Text and Thinking use the Text field directly; all other types use +// their respective content field. +type ContentPart struct { + ContentType string + + // Text/Thinking — "text" field at top level + Text string + + // Structured content — "content" field wrapping a domain object. + // Only one is set based on ContentType. + ToolCallContent *ToolCall + ToolResultContent *ToolResult + ResourceContent *Resource + ResourceRefContent *ResourceReference + PromptRequestContent *PromptRequest + PromptResultContent *PromptResult + ImageContent *ImageSource + VideoContent *VideoSource + AudioContent *AudioSource + DocumentContent *DocumentSource + + // rawMap captures the full original wire form for content_type + // values this Go SDK doesn't have a typed accessor for. Lets a + // newer Rust runtime emitting a future variant pass through + // older Go bindings without losing data on round-trip — Encode + // emits rawMap verbatim when ContentType isn't a known case. + // Private because users with an unknown ContentType have no + // safe way to interpret it; they can only forward it. + rawMap map[string]any +} + +// EncodeMsgpack produces the tagged-union wire format. +func (cp ContentPart) EncodeMsgpack(enc *msgpack.Encoder) error { + // Helper: a body envelope wrapping a typed `content` value. + body := func(content any) map[string]any { + return map[string]any{ + wireKeyContentType: cp.ContentType, + wireKeyContent: content, + } + } + + switch cp.ContentType { + case ContentTypeText, ContentTypeThinking: + return enc.Encode(map[string]any{ + wireKeyContentType: cp.ContentType, + wireKeyText: cp.Text, + }) + case ContentTypeToolCall: + return enc.Encode(body(cp.ToolCallContent)) + case ContentTypeToolResult: + return enc.Encode(body(cp.ToolResultContent)) + case ContentTypeResource: + return enc.Encode(body(cp.ResourceContent)) + case ContentTypeResourceRef: + return enc.Encode(body(cp.ResourceRefContent)) + case ContentTypePromptRequest: + return enc.Encode(body(cp.PromptRequestContent)) + case ContentTypePromptResult: + return enc.Encode(body(cp.PromptResultContent)) + case ContentTypeImage: + return enc.Encode(body(cp.ImageContent)) + case ContentTypeVideo: + return enc.Encode(body(cp.VideoContent)) + case ContentTypeAudio: + return enc.Encode(body(cp.AudioContent)) + case ContentTypeDocument: + return enc.Encode(body(cp.DocumentContent)) + default: + // Unknown content_type. If we captured the raw wire form on + // decode (forward-compat path), emit it verbatim so we don't + // lose data on round-trip. Otherwise fall back to a minimal + // content_type-only message (a Go-side construction with an + // unrecognized ContentType — rare). + if cp.rawMap != nil { + return enc.Encode(cp.rawMap) + } + out := map[string]any{wireKeyContentType: cp.ContentType} + if cp.Text != "" { + out[wireKeyText] = cp.Text + } + return enc.Encode(out) + } +} + +// DecodeMsgpack reads the tagged-union wire format. +func (cp *ContentPart) DecodeMsgpack(dec *msgpack.Decoder) error { + var raw map[string]any + if err := dec.Decode(&raw); err != nil { + return err + } + + if ct, ok := raw[wireKeyContentType].(string); ok { + cp.ContentType = ct + } + + switch cp.ContentType { + case ContentTypeText, ContentTypeThinking: + if t, ok := raw[wireKeyText].(string); ok { + cp.Text = t + } + case ContentTypeToolCall: + cp.ToolCallContent = decodeAs[ToolCall](raw[wireKeyContent]) + case ContentTypeToolResult: + cp.ToolResultContent = decodeAs[ToolResult](raw[wireKeyContent]) + case ContentTypeResource: + cp.ResourceContent = decodeAs[Resource](raw[wireKeyContent]) + case ContentTypeResourceRef: + cp.ResourceRefContent = decodeAs[ResourceReference](raw[wireKeyContent]) + case ContentTypePromptRequest: + cp.PromptRequestContent = decodeAs[PromptRequest](raw[wireKeyContent]) + case ContentTypePromptResult: + cp.PromptResultContent = decodeAs[PromptResult](raw[wireKeyContent]) + case ContentTypeImage: + cp.ImageContent = decodeAs[ImageSource](raw[wireKeyContent]) + case ContentTypeVideo: + cp.VideoContent = decodeAs[VideoSource](raw[wireKeyContent]) + case ContentTypeAudio: + cp.AudioContent = decodeAs[AudioSource](raw[wireKeyContent]) + case ContentTypeDocument: + cp.DocumentContent = decodeAs[DocumentSource](raw[wireKeyContent]) + default: + // Unknown content_type — preserve the full wire form so + // EncodeMsgpack can pass it through unchanged. Forward + // compat for newer Rust variants the Go SDK doesn't know + // about yet (P2 #17). + cp.rawMap = raw + } + + return nil +} + +// --------------------------------------------------------------------------- +// Content Part Constructors +// --------------------------------------------------------------------------- + +// Constructor functions are named `NewXPart` to avoid shadowing the +// matching `XContent` field on ContentPart. Previously a constructor +// like `ToolCallContent(tc)` had the same name as the field +// `cp.ToolCallContent` — confusing in code and hostile to IDE +// autocomplete. The `New*Part` form mirrors common Go conventions +// (`NewClient`, `NewBuffer`). + +// NewTextPart creates a text content part. +func NewTextPart(text string) ContentPart { + return ContentPart{ContentType: ContentTypeText, Text: text} +} + +// NewThinkingPart creates a thinking content part. +func NewThinkingPart(text string) ContentPart { + return ContentPart{ContentType: ContentTypeThinking, Text: text} +} + +// NewToolCallPart creates a tool_call content part. +func NewToolCallPart(tc ToolCall) ContentPart { + return ContentPart{ContentType: ContentTypeToolCall, ToolCallContent: &tc} +} + +// NewToolResultPart creates a tool_result content part. +func NewToolResultPart(tr ToolResult) ContentPart { + return ContentPart{ContentType: ContentTypeToolResult, ToolResultContent: &tr} +} + +// NewResourcePart creates a resource content part. +func NewResourcePart(r Resource) ContentPart { + return ContentPart{ContentType: ContentTypeResource, ResourceContent: &r} +} + +// NewResourceRefPart creates a resource_ref content part. +func NewResourceRefPart(r ResourceReference) ContentPart { + return ContentPart{ContentType: ContentTypeResourceRef, ResourceRefContent: &r} +} + +// NewPromptRequestPart creates a prompt_request content part. +func NewPromptRequestPart(pr PromptRequest) ContentPart { + return ContentPart{ContentType: ContentTypePromptRequest, PromptRequestContent: &pr} +} + +// NewPromptResultPart creates a prompt_result content part. +func NewPromptResultPart(pr PromptResult) ContentPart { + return ContentPart{ContentType: ContentTypePromptResult, PromptResultContent: &pr} +} + +// NewImagePart creates an image content part. +func NewImagePart(img ImageSource) ContentPart { + return ContentPart{ContentType: ContentTypeImage, ImageContent: &img} +} + +// NewVideoPart creates a video content part. +func NewVideoPart(vid VideoSource) ContentPart { + return ContentPart{ContentType: ContentTypeVideo, VideoContent: &vid} +} + +// NewAudioPart creates an audio content part. +func NewAudioPart(aud AudioSource) ContentPart { + return ContentPart{ContentType: ContentTypeAudio, AudioContent: &aud} +} + +// NewDocumentPart creates a document content part. +func NewDocumentPart(doc DocumentSource) ContentPart { + return ContentPart{ContentType: ContentTypeDocument, DocumentContent: &doc} +} + +// --------------------------------------------------------------------------- +// Domain Objects +// --------------------------------------------------------------------------- + +// ToolCall represents a tool invocation request. +type ToolCall struct { + ToolCallID string `msgpack:"tool_call_id"` + Name string `msgpack:"name"` + Arguments map[string]any `msgpack:"arguments,omitempty"` + Namespace string `msgpack:"namespace,omitempty"` +} + +// ToolResult represents the output of a tool execution. +type ToolResult struct { + ToolCallID string `msgpack:"tool_call_id"` + ToolName string `msgpack:"tool_name"` + Content any `msgpack:"content,omitempty"` + IsError bool `msgpack:"is_error,omitempty"` +} + +// Resource represents an embedded resource with content (MCP). +type Resource struct { + ResourceRequestID string `msgpack:"resource_request_id"` + URI string `msgpack:"uri"` + Name string `msgpack:"name,omitempty"` + Description string `msgpack:"description,omitempty"` + ResourceType string `msgpack:"resource_type"` + Content string `msgpack:"content,omitempty"` + Blob []byte `msgpack:"blob,omitempty"` + MimeType string `msgpack:"mime_type,omitempty"` + SizeBytes *uint64 `msgpack:"size_bytes,omitempty"` + Annotations map[string]any `msgpack:"annotations,omitempty"` + Version string `msgpack:"version,omitempty"` +} + +// ResourceReference is a lightweight resource reference without content. +type ResourceReference struct { + ResourceRequestID string `msgpack:"resource_request_id"` + URI string `msgpack:"uri"` + Name string `msgpack:"name,omitempty"` + ResourceType string `msgpack:"resource_type"` + RangeStart *uint64 `msgpack:"range_start,omitempty"` + RangeEnd *uint64 `msgpack:"range_end,omitempty"` + Selector string `msgpack:"selector,omitempty"` +} + +// PromptRequest represents a prompt template invocation request (MCP). +type PromptRequest struct { + PromptRequestID string `msgpack:"prompt_request_id"` + Name string `msgpack:"name"` + Arguments map[string]any `msgpack:"arguments,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` +} + +// PromptResult represents a rendered prompt template result. +type PromptResult struct { + PromptRequestID string `msgpack:"prompt_request_id"` + PromptName string `msgpack:"prompt_name"` + Messages []Message `msgpack:"messages,omitempty"` + Content string `msgpack:"content,omitempty"` + IsError bool `msgpack:"is_error,omitempty"` + ErrorMessage string `msgpack:"error_message,omitempty"` +} + +// --------------------------------------------------------------------------- +// Media Source Types +// --------------------------------------------------------------------------- + +// ImageSource holds image data (URL or base64). +type ImageSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` +} + +// VideoSource holds video data (URL or base64). +type VideoSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` + DurationMs *uint64 `msgpack:"duration_ms,omitempty"` +} + +// AudioSource holds audio data (URL or base64). +type AudioSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` + DurationMs *uint64 `msgpack:"duration_ms,omitempty"` +} + +// DocumentSource holds document data (URL or base64). +type DocumentSource struct { + SourceType string `msgpack:"type"` + Data string `msgpack:"data"` + MediaType string `msgpack:"media_type,omitempty"` + Title string `msgpack:"title,omitempty"` +} + +// --------------------------------------------------------------------------- +// Decode helpers — extract typed domain objects from a decoded `any` value. +// --------------------------------------------------------------------------- + +// decodeAs re-encodes a decoded msgpack value and unmarshals it into a +// typed struct, letting the struct's msgpack tags drive field selection. +// Replaces 11 hand-rolled decoders that each had to enumerate fields +// manually — that pattern was the source of the silent data loss +// reviewer flagged in #13 (`DurationMs`, `RangeStart/End`, `Blob`, +// `SizeBytes`, `Messages` were all dropped). Adding a new field to a +// struct now Just Works without a corresponding decoder edit. +// +// Cost: an extra msgpack marshal + unmarshal per content part. This is +// on the per-message decode path, not per-pipeline-step. msgpack is +// fast; in practice it's microseconds. If this ever shows up on a hot +// path we can switch to msgpack's `Decoder.Query()` or hand-roll +// targeted decoders for specific high-volume types. +func decodeAs[T any](v any) *T { + if v == nil { + return nil + } + bytes, err := msgpack.Marshal(v) + if err != nil { + return nil + } + var out T + if err := msgpack.Unmarshal(bytes, &out); err != nil { + return nil + } + return &out +} diff --git a/go/cpex/cmf_test.go b/go/cpex/cmf_test.go new file mode 100644 index 00000000..e329aabe --- /dev/null +++ b/go/cpex/cmf_test.go @@ -0,0 +1,262 @@ +// Location: ./go/cpex/cmf_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MessagePack roundtrip coverage for the CMF tagged-union ContentPart. +// +// Pass 4 of the CGO review fixed an audit's worth of dropped fields +// across the per-variant decoders (DurationMs, RangeStart/End, Blob, +// SizeBytes, Messages, etc.) and rewrote them on top of a generic +// `decodeAs[T]` helper. These tests pin that fix: every variant must +// roundtrip msgpack with all its fields intact, and unknown variants +// must passthrough via the rawMap path. + +package cpex + +import ( + "reflect" + "testing" + + "github.com/vmihailenco/msgpack/v5" +) + +// roundTripContentPart encodes via ContentPart.EncodeMsgpack and +// decodes via ContentPart.DecodeMsgpack — the same path the FFI +// uses on either side of the boundary. Returns the decoded value +// for the caller to deep-compare. +func roundTripContentPart(t *testing.T, original ContentPart) ContentPart { + t.Helper() + bytes, err := msgpack.Marshal(original) + if err != nil { + t.Fatalf("encode failed: %v", err) + } + var decoded ContentPart + if err := msgpack.Unmarshal(bytes, &decoded); err != nil { + t.Fatalf("decode failed: %v", err) + } + return decoded +} + +func u64ptr(v uint64) *uint64 { return &v } + +// Each subtest below builds a fully-populated variant — every field +// present, including the ones Pass 4's review found dropped — and +// asserts roundtrip equality via reflect.DeepEqual. A missing field +// in either the encoder or decoder produces a diff and a clean +// failure pointing at the variant. + +func TestContentPart_RoundTripText(t *testing.T) { + original := NewTextPart("hello world") + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("text roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripThinking(t *testing.T) { + original := NewThinkingPart("internal monologue") + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("thinking roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripToolCall(t *testing.T) { + original := NewToolCallPart(ToolCall{ + ToolCallID: "call-1", + Name: "search", + Arguments: map[string]any{"q": "anthropic", "limit": int64(10)}, + Namespace: "tools.web", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("tool_call roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripToolResult(t *testing.T) { + original := NewToolResultPart(ToolResult{ + ToolCallID: "call-1", + ToolName: "search", + Content: "result body", + IsError: false, + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("tool_result roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripResource(t *testing.T) { + // All Pass 4-restored fields populated: Blob, SizeBytes, plus the + // existing ones. If decodeAs[Resource] drops any of these the + // reflect.DeepEqual catches it. + original := NewResourcePart(Resource{ + ResourceRequestID: "req-1", + URI: "file://x", + Name: "x.txt", + Description: "a file", + ResourceType: "text", + Content: "body", + Blob: []byte{0x01, 0x02, 0x03}, + MimeType: "text/plain", + SizeBytes: u64ptr(3), + Annotations: map[string]any{"tag": "v1"}, + Version: "v1", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("resource roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripResourceRef(t *testing.T) { + // RangeStart/RangeEnd were both dropped pre-Pass 4 — explicit fields here. + original := NewResourceRefPart(ResourceReference{ + ResourceRequestID: "req-2", + URI: "file://y", + Name: "y.txt", + ResourceType: "text", + RangeStart: u64ptr(0), + RangeEnd: u64ptr(100), + Selector: "$.body", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("resource_ref roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripPromptRequest(t *testing.T) { + original := NewPromptRequestPart(PromptRequest{ + PromptRequestID: "pr-1", + Name: "summarize", + Arguments: map[string]any{"length": "short"}, + ServerID: "srv-1", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("prompt_request roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripPromptResult(t *testing.T) { + // Messages was dropped entirely pre-Pass 4. Populating it here + // pins the fix. + original := NewPromptResultPart(PromptResult{ + PromptRequestID: "pr-1", + PromptName: "summarize", + Messages: []Message{ + NewMessage("user", NewTextPart("input")), + NewMessage("assistant", NewTextPart("output")), + }, + Content: "summary text", + IsError: false, + ErrorMessage: "", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("prompt_result roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripImage(t *testing.T) { + original := NewImagePart(ImageSource{ + SourceType: "base64", + Data: "aW1hZ2U=", + MediaType: "image/png", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("image roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripVideo(t *testing.T) { + // DurationMs was dropped pre-Pass 4 — explicit field here. + original := NewVideoPart(VideoSource{ + SourceType: "url", + Data: "https://example/v.mp4", + MediaType: "video/mp4", + DurationMs: u64ptr(15000), + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("video roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripAudio(t *testing.T) { + // Same DurationMs drop — explicit field. + original := NewAudioPart(AudioSource{ + SourceType: "base64", + Data: "YXVkaW8=", + MediaType: "audio/mp3", + DurationMs: u64ptr(5500), + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("audio roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +func TestContentPart_RoundTripDocument(t *testing.T) { + original := NewDocumentPart(DocumentSource{ + SourceType: "url", + Data: "https://example/d.pdf", + MediaType: "application/pdf", + Title: "doc", + }) + got := roundTripContentPart(t, original) + if !reflect.DeepEqual(original, got) { + t.Errorf("document roundtrip mismatch:\n want: %+v\n got: %+v", original, got) + } +} + +// Forward-compat: a content_type the Go decoder doesn't recognize +// must passthrough via rawMap so that re-encoding produces the same +// wire bytes. This protects against silent drops when Rust adds a +// variant that Go hasn't been updated for yet. +func TestContentPart_UnknownVariantPassesThrough(t *testing.T) { + // Build a payload by encoding a known structure with an + // unrecognized content_type tag — simulate Rust shipping a future + // variant. + wireMap := map[string]any{ + "content_type": "future_variant_v2", + "content": map[string]any{ + "foo": "bar", + "n": int64(42), + }, + } + originalBytes, err := msgpack.Marshal(wireMap) + if err != nil { + t.Fatalf("encode wire fixture failed: %v", err) + } + + var cp ContentPart + if err := msgpack.Unmarshal(originalBytes, &cp); err != nil { + t.Fatalf("decode unknown variant failed: %v", err) + } + + // Re-encode and compare to the original wire bytes. The tag and + // the body must both survive intact — that's what rawMap is for. + roundTripBytes, err := msgpack.Marshal(cp) + if err != nil { + t.Fatalf("re-encode failed: %v", err) + } + + // Decode both sides into generic maps to compare semantically + // (msgpack key ordering is not guaranteed across encode passes). + var originalMap, roundTripMap map[string]any + if err := msgpack.Unmarshal(originalBytes, &originalMap); err != nil { + t.Fatalf("decode original to map: %v", err) + } + if err := msgpack.Unmarshal(roundTripBytes, &roundTripMap); err != nil { + t.Fatalf("decode roundtrip to map: %v", err) + } + if !reflect.DeepEqual(originalMap, roundTripMap) { + t.Errorf("unknown variant passthrough mismatch:\n want: %+v\n got: %+v", + originalMap, roundTripMap) + } +} diff --git a/go/cpex/constants.go b/go/cpex/constants.go new file mode 100644 index 00000000..45ce3858 --- /dev/null +++ b/go/cpex/constants.go @@ -0,0 +1,66 @@ +// Location: ./go/cpex/constants.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Wire-format constants used across the encoder, decoder, and constructor +// helpers. Centralizing them prevents the silent mismatches you'd get from +// a typo in one of the three sites a value would otherwise be duplicated. +// +// All values match Rust's serde tag/field names exactly — adding a new +// variant requires syncing this file with the corresponding Rust enum. + +package cpex + +// Payload type IDs — must match Rust's PAYLOAD_* constants in +// crates/cpex-ffi/src/lib.rs. Used as the `payload_type` discriminator +// when crossing the FFI boundary. +const ( + // PayloadGeneric is a generic JSON-like payload (map[string]any). + PayloadGeneric uint8 = 0 + // PayloadCMFMessage is a CMF MessagePayload. + PayloadCMFMessage uint8 = 1 +) + +// ContentType values — the discriminator for ContentPart's tagged union. +// Wire-compatible with Rust's `#[serde(tag = "content_type")]` enum in +// `crates/cpex-core/src/cmf/`. Every string literal in cmf.go's encoder / +// decoder / constructor switches resolves to one of these. +const ( + ContentTypeText = "text" + ContentTypeThinking = "thinking" + ContentTypeToolCall = "tool_call" + ContentTypeToolResult = "tool_result" + ContentTypeResource = "resource" + ContentTypeResourceRef = "resource_ref" + ContentTypePromptRequest = "prompt_request" + ContentTypePromptResult = "prompt_result" + ContentTypeImage = "image" + ContentTypeVideo = "video" + ContentTypeAudio = "audio" + ContentTypeDocument = "document" +) + +// Wire-format keys for the ContentPart tagged-union envelope. Unexported +// because they're an internal serialization detail — users build +// ContentPart via the constructors (NewTextPart, NewToolCallPart, …) +// and never touch the wire keys directly. +const ( + wireKeyContentType = "content_type" + wireKeyContent = "content" + wireKeyText = "text" +) + +// FFI return codes from libcpex_ffi. 0 means success; negative codes +// classify the failure. Stable wire ABI with the Rust side — values must +// match `RC_*` constants in `crates/cpex-ffi/src/lib.rs`. Don't renumber. +const ( + rcOK = 0 + rcInvalidHandle = -1 + rcInvalidInput = -2 + rcParseError = -3 + rcPipelineError = -4 + rcSerializeError = -5 + rcTimeout = -6 + rcPanic = -7 +) diff --git a/go/cpex/errors.go b/go/cpex/errors.go new file mode 100644 index 00000000..4a193ae6 --- /dev/null +++ b/go/cpex/errors.go @@ -0,0 +1,88 @@ +// Location: ./go/cpex/errors.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Sentinel errors for FFI return-code classification. +// +// The libcpex_ffi C API returns int codes (0 = success, negative = +// failure). Each negative code maps to a stable sentinel error here — +// callers can use `errors.Is(err, ErrCpexTimeout)` to handle specific +// failure modes (retry on timeout, abort on panic, etc.) instead of +// regex-matching opaque "invoke failed" strings. + +package cpex + +import ( + "errors" + "fmt" +) + +// Sentinel errors returned by the FFI wrapper. Compare with `errors.Is`. +// All errors returned from PluginManager / BackgroundTasks methods that +// originated in a non-zero FFI return code wrap one of these. +var ( + // ErrCpexInvalidHandle: the manager handle is null or has been + // shutdown. Trying to use a manager after Shutdown produces this. + ErrCpexInvalidHandle = errors.New("cpex: invalid handle (manager null or shutdown)") + + // ErrCpexInvalidInput: caller-supplied input was malformed — bad + // UTF-8, null pointer where data was required, oversized buffer, + // unknown payload type. Caller bug; fix the input and retry. + ErrCpexInvalidInput = errors.New("cpex: invalid input") + + // ErrCpexParse: parse / deserialize step failed (YAML config, + // MessagePack payload, MessagePack extensions). Caller bug; fix + // the data shape and retry. + ErrCpexParse = errors.New("cpex: parse / deserialize failed") + + // ErrCpexPipeline: pipeline / lifecycle step failed — load_config + // returned Err, initialize failed, or a plugin signalled a + // runtime failure that wasn't a timeout or panic. + ErrCpexPipeline = errors.New("cpex: pipeline / lifecycle error") + + // ErrCpexSerialize: result serialization failed after the pipeline + // ran successfully. Usually OOM on rmp_serde::to_vec_named or an + // unserializable JSON value. Rare; not retryable on its own. + ErrCpexSerialize = errors.New("cpex: result serialize failed") + + // ErrCpexTimeout: wall-clock timeout exceeded inside the FFI + // boundary. The plugin is likely CPU-bound or blocking the OS + // thread without yielding (per-plugin tokio timeouts can't catch + // non-cooperative work). Caller may retry but probably wants to + // disable the offending plugin first. + ErrCpexTimeout = errors.New("cpex: wall-clock timeout exceeded") + + // ErrCpexPanic: a plugin panicked across the FFI boundary; the + // panic was caught (preventing UB / process abort) but the + // invocation is lost. Same plugin will likely panic again. + ErrCpexPanic = errors.New("cpex: plugin panicked at FFI boundary") +) + +// errorFromRC maps an FFI return code to a typed error. `op` is included +// in the wrapped message so the caller can tell which operation failed +// without losing the sentinel for `errors.Is` checks. +func errorFromRC(rc int, op string) error { + switch rc { + case rcOK: + return nil + case rcInvalidHandle: + return fmt.Errorf("%s: %w", op, ErrCpexInvalidHandle) + case rcInvalidInput: + return fmt.Errorf("%s: %w", op, ErrCpexInvalidInput) + case rcParseError: + return fmt.Errorf("%s: %w", op, ErrCpexParse) + case rcPipelineError: + return fmt.Errorf("%s: %w", op, ErrCpexPipeline) + case rcSerializeError: + return fmt.Errorf("%s: %w", op, ErrCpexSerialize) + case rcTimeout: + return fmt.Errorf("%s: %w", op, ErrCpexTimeout) + case rcPanic: + return fmt.Errorf("%s: %w", op, ErrCpexPanic) + default: + // Unknown code — wrap a generic error including the rc so + // the caller can at least see the raw value. + return fmt.Errorf("%s: cpex: unknown FFI return code %d", op, rc) + } +} diff --git a/go/cpex/ffi.go b/go/cpex/ffi.go new file mode 100644 index 00000000..67e9c848 --- /dev/null +++ b/go/cpex/ffi.go @@ -0,0 +1,66 @@ +// Location: ./go/cpex/ffi.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CGO declarations for the CPEX FFI layer. +// +// Declares the C function signatures from libcpex_ffi. These are +// opaque handles — Go callers use the PluginManager wrapper in +// manager.go rather than calling these directly. + +package cpex + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_ffi + +#include +#include + +// Opaque handles +typedef void* CpexManager; +typedef void* CpexContextTable; +typedef void* CpexBackgroundTasks; + +// Runtime configuration +int cpex_configure_runtime(int worker_threads); + +// Manager lifecycle +CpexManager cpex_manager_new(const char* config_yaml, int config_len); +CpexManager cpex_manager_new_default(); +int cpex_load_config(CpexManager mgr, const char* config_yaml, int config_len); +int cpex_initialize(CpexManager mgr); +void cpex_shutdown(CpexManager mgr); + +// Query +int cpex_has_hooks_for(CpexManager mgr, const char* hook_name, int hook_len); +int cpex_plugin_count(CpexManager mgr); +int cpex_is_initialized(CpexManager mgr); +int cpex_plugin_names(CpexManager mgr, uint8_t** names_msgpack_out, int* names_len_out); + +// Invoke +int cpex_invoke( + CpexManager mgr, + const char* hook_name, int hook_len, + uint8_t payload_type, + const uint8_t* payload_msgpack, int payload_len, + const uint8_t* extensions_msgpack, int extensions_len, + CpexContextTable context_table, + uint8_t** result_msgpack_out, int* result_len_out, + CpexContextTable* context_table_out, + CpexBackgroundTasks* bg_handle_out +); + +// Background tasks +int cpex_wait_background( + CpexManager mgr, + CpexBackgroundTasks bg_handle, + uint8_t** errors_msgpack_out, int* errors_len_out +); +void cpex_free_background(CpexBackgroundTasks bg_handle); + +// Memory +void cpex_free_context_table(CpexContextTable ct); +void cpex_free_bytes(uint8_t* ptr, int len); +*/ +import "C" diff --git a/go/cpex/go.mod b/go/cpex/go.mod new file mode 100644 index 00000000..d71e10b0 --- /dev/null +++ b/go/cpex/go.mod @@ -0,0 +1,8 @@ +module github.com/contextforge-org/contextforge-plugins-framework/go/cpex + +go 1.25.4 + +require ( + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect +) diff --git a/go/cpex/go.sum b/go/cpex/go.sum new file mode 100644 index 00000000..84eba6c9 --- /dev/null +++ b/go/cpex/go.sum @@ -0,0 +1,4 @@ +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= diff --git a/go/cpex/manager.go b/go/cpex/manager.go new file mode 100644 index 00000000..911bb7ec --- /dev/null +++ b/go/cpex/manager.go @@ -0,0 +1,550 @@ +// Location: ./go/cpex/manager.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// PluginManager — Go wrapper for the CPEX plugin runtime. +// +// Owns the lifecycle of the Rust PluginManager via cgo. Provides +// the public API that Go host systems call to register factories, +// load config, initialize plugins, and invoke hooks. +// +// Lifecycle: +// +// NewPluginManagerDefault() → RegisterFactories() → LoadConfig() → Initialize() → InvokeByName() → Shutdown() +// +// Payloads and extensions are serialized to MessagePack when +// crossing the FFI boundary. ContextTable and BackgroundTasks +// are opaque handles to Rust-owned data. + +package cpex + +import ( + "errors" + "fmt" + "runtime" + "sync" + "unsafe" + + "github.com/vmihailenco/msgpack/v5" +) + +/* +#include +#include + +// Opaque handles +typedef void* CpexManager; +typedef void* CpexContextTable; +typedef void* CpexBackgroundTasks; + +// Extern declarations - implemented in libcpex_ffi. +// +// These are duplicated with ffi.go's preamble. CGO does NOT merge +// declarations across multiple files' preambles in a single package - +// each file's `import "C"` resolves only against its own preceding +// comment block. The reviewer's "remove these, CGO resolves +// package-wide" suggestion was tested and didn't work; CGO reports +// "could not determine what C.cpex_X refers to" for any function +// declared only in a sibling file's preamble. +// +// If you change a signature, edit BOTH this block and ffi.go's. The +// build will fail loudly on a mismatch (Go's C type system catches +// it), but the duplication is unavoidable until either: +// - all cgo entry points move to ffi.go (refactor), or +// - we generate the C header via cbindgen and #include it from +// both files. +extern int cpex_configure_runtime(int worker_threads); +extern CpexManager cpex_manager_new(const char* config_yaml, int config_len); +extern CpexManager cpex_manager_new_default(); +extern int cpex_load_config(CpexManager mgr, const char* config_yaml, int config_len); +extern int cpex_initialize(CpexManager mgr); +extern void cpex_shutdown(CpexManager mgr); +extern int cpex_has_hooks_for(CpexManager mgr, const char* hook_name, int hook_len); +extern int cpex_plugin_count(CpexManager mgr); +extern int cpex_is_initialized(CpexManager mgr); +extern int cpex_plugin_names(CpexManager mgr, uint8_t** names_msgpack_out, int* names_len_out); +extern int cpex_invoke( + CpexManager mgr, + const char* hook_name, int hook_len, + uint8_t payload_type, + const uint8_t* payload_msgpack, int payload_len, + const uint8_t* extensions_msgpack, int extensions_len, + CpexContextTable context_table, + uint8_t** result_msgpack_out, int* result_len_out, + CpexContextTable* context_table_out, + CpexBackgroundTasks* bg_handle_out +); +extern int cpex_wait_background( + CpexManager mgr, + CpexBackgroundTasks bg_handle, + uint8_t** errors_msgpack_out, int* errors_len_out +); +extern void cpex_free_background(CpexBackgroundTasks bg_handle); +extern void cpex_free_context_table(CpexContextTable ct); +extern void cpex_free_bytes(uint8_t* ptr, int len); +*/ +import "C" + +// PluginManager manages the lifecycle of CPEX plugins and hook dispatch. +// Wraps the Rust PluginManager — all plugin execution happens in Rust. +// +// Concurrency: `mu` serializes lifecycle (Shutdown, finalizer) against +// in-flight cgo calls. Operations (Invoke, Initialize, queries) take +// the *read* lock and may run in parallel with each other — the +// underlying Rust API is `&self` and ArcSwap-backed, so concurrent +// dispatch is safe. `Shutdown` and the GC finalizer take the *write* +// lock so the C handle can't be freed while a cgo call is mid-flight. +type PluginManager struct { + mu sync.RWMutex + handle C.CpexManager // protected by mu; nil after Shutdown +} + +// ContextTable holds per-plugin context state across hook invocations. +// Opaque handle to Rust-owned data — not serialized. +type ContextTable struct { + handle C.CpexContextTable +} + +// BackgroundTasks holds fire-and-forget task handles. +// Opaque handle to Rust-owned data — not serialized. +// +// Holds *PluginManager (not the raw C handle) so `Wait()` can check +// `mgr.handle != nil` under the manager's RWMutex — preventing a +// use-after-free if the manager was Shutdown after the invoke that +// produced this `BackgroundTasks`. +type BackgroundTasks struct { + handle C.CpexBackgroundTasks + mgr *PluginManager +} + +// ConfigureRuntime sets the worker thread count for the shared tokio +// runtime that backs every PluginManager in the process. Must be +// called before the first NewPluginManager — once a manager has +// been created the runtime is fixed for the process lifetime. +// +// Precedence: ConfigureRuntime > CPEX_FFI_WORKER_THREADS env var > +// num_cpus default. Returns ErrCpexInvalidInput if workerThreads <= 0 +// or the runtime has already been initialized. +func ConfigureRuntime(workerThreads int) error { + rc := C.cpex_configure_runtime(C.int(workerThreads)) + return errorFromRC(int(rc), "ConfigureRuntime") +} + +// finalizeManager is the GC fallback path when the caller forgot to +// call Shutdown. Takes the write lock so it can't race with an +// explicit Shutdown that's already running. +func finalizeManager(m *PluginManager) { + m.mu.Lock() + defer m.mu.Unlock() + if m.handle != nil { + C.cpex_shutdown(m.handle) + m.handle = nil + } +} + +// NewPluginManager creates a manager from a YAML config string. +// Built-in Rust plugin factories are registered automatically. +func NewPluginManager(yaml string) (*PluginManager, error) { + cYaml := C.CString(yaml) + defer C.free(unsafe.Pointer(cYaml)) + + handle := C.cpex_manager_new(cYaml, C.int(len(yaml))) + if handle == nil { + return nil, errors.New("cpex: failed to create plugin manager from config") + } + + mgr := &PluginManager{handle: handle} + runtime.SetFinalizer(mgr, finalizeManager) + return mgr, nil +} + +// NewPluginManagerDefault creates a manager with default config. +// Useful when registering plugins programmatically. +func NewPluginManagerDefault() (*PluginManager, error) { + handle := C.cpex_manager_new_default() + if handle == nil { + return nil, errors.New("cpex: failed to create default plugin manager") + } + + mgr := &PluginManager{handle: handle} + runtime.SetFinalizer(mgr, finalizeManager) + return mgr, nil +} + +// FactoryRegistrar is a function that registers plugin factories on the +// manager's internal handle. The handle is an opaque C pointer — callers +// pass it to their own extern C registration function. +type FactoryRegistrar func(handle unsafe.Pointer) error + +// RegisterFactories calls fn with the manager's internal C handle, +// allowing callers to register plugin factories via their own FFI. +// Must be called before LoadConfig. +func (m *PluginManager) RegisterFactories(fn FactoryRegistrar) error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("RegisterFactories: %w", ErrCpexInvalidHandle) + } + return fn(unsafe.Pointer(m.handle)) +} + +// LoadConfig loads a YAML config string into the manager. +// Factories must be registered before calling this method. +// +// On failure, the returned error wraps one of the typed sentinels +// (ErrCpexInvalidHandle, ErrCpexInvalidInput, ErrCpexParse, +// ErrCpexPipeline, ErrCpexPanic). Use `errors.Is` to classify. +func (m *PluginManager) LoadConfig(yaml string) error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("LoadConfig: %w", ErrCpexInvalidHandle) + } + + cYaml := C.CString(yaml) + defer C.free(unsafe.Pointer(cYaml)) + + rc := C.cpex_load_config(m.handle, cYaml, C.int(len(yaml))) + return errorFromRC(int(rc), "LoadConfig") +} + +// Initialize calls Initialize on all registered plugins. +// Must be called before invoking any hooks. +// +// On failure, the returned error wraps one of the typed sentinels +// (ErrCpexInvalidHandle, ErrCpexPipeline, ErrCpexTimeout, ErrCpexPanic). +func (m *PluginManager) Initialize() error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("Initialize: %w", ErrCpexInvalidHandle) + } + + rc := C.cpex_initialize(m.handle) + return errorFromRC(int(rc), "Initialize") +} + +// Shutdown gracefully shuts down all plugins and releases resources. +// After this call, the manager is invalid and must not be used. +// +// Takes the write lock to ensure no in-flight cgo call is racing with +// the destruction of the C handle. Also clears the GC finalizer so +// the finalizer can't fire later and double-free. +func (m *PluginManager) Shutdown() { + m.mu.Lock() + defer m.mu.Unlock() + if m.handle == nil { + return + } + // Clear the finalizer first — if cpex_shutdown panics or aborts, + // we still don't want the finalizer to run later and try again. + runtime.SetFinalizer(m, nil) + C.cpex_shutdown(m.handle) + m.handle = nil +} + +// HasHooksFor returns true if any plugins are registered for the hook. +// No serialization — just a hash lookup across the FFI boundary. +func (m *PluginManager) HasHooksFor(hookName string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return false + } + cName := C.CString(hookName) + defer C.free(unsafe.Pointer(cName)) + return C.cpex_has_hooks_for(m.handle, cName, C.int(len(hookName))) == 1 +} + +// PluginCount returns the number of registered plugins. +func (m *PluginManager) PluginCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return 0 + } + return int(C.cpex_plugin_count(m.handle)) +} + +// IsInitialized reports whether Initialize has been called and Shutdown +// has not. Useful for agent control loops that may inspect manager +// state before deciding whether to dispatch. +func (m *PluginManager) IsInitialized() bool { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return false + } + return C.cpex_is_initialized(m.handle) == 1 +} + +// PluginNames returns the names of all registered plugins. Order is +// not stable across calls — the underlying registry uses a HashMap. +func (m *PluginManager) PluginNames() ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return nil, fmt.Errorf("PluginNames: %w", ErrCpexInvalidHandle) + } + + var namesPtr *C.uint8_t + var namesLen C.int + rc := C.cpex_plugin_names(m.handle, &namesPtr, &namesLen) + if rc != 0 { + return nil, errorFromRC(int(rc), "PluginNames") + } + + bytes := C.GoBytes(unsafe.Pointer(namesPtr), namesLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(namesPtr)), namesLen) + + var names []string + if err := msgpack.Unmarshal(bytes, &names); err != nil { + return nil, fmt.Errorf("PluginNames: decode failed: %w", err) + } + return names, nil +} + +// InvokeByName invokes a hook by name with a payload and extensions. +// Payload and extensions are serialized to MessagePack internally. +// The ContextTable is an opaque handle — pass nil on the first call, +// then thread result's ContextTable into subsequent calls. +func (m *PluginManager) InvokeByName( + hookName string, + payloadType uint8, + payload any, + extensions *Extensions, + contextTable *ContextTable, +) (*PipelineResult, *ContextTable, *BackgroundTasks, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return nil, nil, nil, fmt.Errorf("InvokeByName: %w", ErrCpexInvalidHandle) + } + + // Serialize payload to MessagePack + payloadBytes, err := msgpack.Marshal(payload) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: payload marshal failed: %w", err) + } + + // Serialize extensions to MessagePack + var extBytes []byte + if extensions != nil { + extBytes, err = msgpack.Marshal(extensions) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: extensions marshal failed: %w", err) + } + } + + // Prepare C args + cHookName := C.CString(hookName) + defer C.free(unsafe.Pointer(cHookName)) + + // Pass the context-table handle to Rust but DO NOT nil our local + // reference until we know Rust succeeded. Rust consumes the handle + // only at the moment of invoke (after all input validation), so + // pre-invoke failures (bad payload, bad extensions, etc.) leave + // the handle untouched and the caller's ContextTable remains valid. + // + // Caveat: on a post-invoke failure (rare — only result-serialization + // OOM), Rust has consumed the box but doesn't write ctOut, so the + // caller's ContextTable handle becomes dangling. The caller should + // not reuse a ContextTable after an InvokeByName error. + var ctHandle C.CpexContextTable + if contextTable != nil { + ctHandle = contextTable.handle + } + + var resultPtr *C.uint8_t + var resultLen C.int + var ctOut C.CpexContextTable + var bgOut C.CpexBackgroundTasks + + var payloadPtr *C.uint8_t + if len(payloadBytes) > 0 { + payloadPtr = (*C.uint8_t)(unsafe.Pointer(&payloadBytes[0])) + } + + var extPtr *C.uint8_t + var extLen C.int + if len(extBytes) > 0 { + extPtr = (*C.uint8_t)(unsafe.Pointer(&extBytes[0])) + extLen = C.int(len(extBytes)) + } + + rc := C.cpex_invoke( + m.handle, + cHookName, C.int(len(hookName)), + C.uint8_t(payloadType), + payloadPtr, C.int(len(payloadBytes)), + extPtr, extLen, + ctHandle, + &resultPtr, &resultLen, + &ctOut, + &bgOut, + ) + + if rc != 0 { + return nil, nil, nil, errorFromRC(int(rc), "InvokeByName") + } + + // Rust succeeded — it consumed ctHandle and produced ctOut. + // NOW it's safe to nil the caller's reference (the original Box + // was consumed by Rust; its successor is in ctOut). + if contextTable != nil { + contextTable.handle = nil + } + + // Deserialize result from MessagePack + resultBytes := C.GoBytes(unsafe.Pointer(resultPtr), resultLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(resultPtr)), resultLen) + + var result PipelineResult + if err := msgpack.Unmarshal(resultBytes, &result); err != nil { + return nil, nil, nil, fmt.Errorf("cpex: result unmarshal failed: %w", err) + } + + // Wrap opaque handles + resultCT := &ContextTable{handle: ctOut} + runtime.SetFinalizer(resultCT, func(ct *ContextTable) { + ct.Close() + }) + + // Hold *PluginManager (not the raw C handle) so Wait() can check + // mgr.handle != nil under the manager's mutex — preventing UAF + // if Shutdown is called between this invoke and Wait(). + bg := &BackgroundTasks{handle: bgOut, mgr: m} + + return &result, resultCT, bg, nil +} + +// Invoke is the typed invoke path. Calls InvokeByName and deserializes +// the modified payload and extensions into concrete Go types. +// +// Example: +// +// result, ct, bg, err := cpex.Invoke[cpex.MessagePayload]( +// mgr, "cmf.tool_pre_invoke", cpex.PayloadCMFMessage, +// payload, ext, nil, +// ) +// if !result.IsDenied() && result.ModifiedPayload != nil { +// fmt.Println(result.ModifiedPayload.Message.Role) +// } +func Invoke[P any]( + m *PluginManager, + hookName string, + payloadType uint8, + payload P, + extensions *Extensions, + contextTable *ContextTable, +) (*TypedPipelineResult[P], *ContextTable, *BackgroundTasks, error) { + raw, ct, bg, err := m.InvokeByName(hookName, payloadType, payload, extensions, contextTable) + if err != nil { + return nil, nil, nil, err + } + + typed := &TypedPipelineResult[P]{ + ContinueProcessing: raw.ContinueProcessing, + Violation: raw.Violation, + Errors: raw.Errors, + Metadata: raw.Metadata, + PayloadType: raw.PayloadType, + } + + // Deserialize modified payload if present + if len(raw.ModifiedPayload) > 0 { + var v P + if err := msgpack.Unmarshal(raw.ModifiedPayload, &v); err != nil { + return nil, ct, bg, fmt.Errorf("cpex: modified payload unmarshal failed: %w", err) + } + typed.ModifiedPayload = &v + } + + // Deserialize modified extensions if present + if len(raw.ModifiedExtensions) > 0 { + var ext Extensions + if err := msgpack.Unmarshal(raw.ModifiedExtensions, &ext); err != nil { + return nil, ct, bg, fmt.Errorf("cpex: modified extensions unmarshal failed: %w", err) + } + typed.ModifiedExtensions = &ext + } + + return typed, ct, bg, nil +} + +// Wait blocks until all background tasks complete. +// Returns structured errors from any tasks that failed (panicked, +// errored, or timed out), plus an error if the underlying FFI call +// failed (e.g., the manager was already shutdown). On FFI failure the +// returned slice is nil. +// +// Each PluginError carries the failing plugin's name, a message, an +// optional error code, structured details, and an optional protocol +// error code (JSON-RPC / HTTP) — enough for an agent to classify +// failures without parsing strings. +// +// Holds the manager's read lock for the duration of the cgo call so +// the C handle can't be freed by a concurrent Shutdown. +func (bg *BackgroundTasks) Wait() ([]PluginError, error) { + if bg.handle == nil { + return nil, nil + } + if bg.mgr == nil { + return nil, fmt.Errorf("BackgroundTasks.Wait: %w", ErrCpexInvalidHandle) + } + + bg.mgr.mu.RLock() + defer bg.mgr.mu.RUnlock() + if bg.mgr.handle == nil { + // Rust still owns the BackgroundTasks box. The Rust-side + // `cpex_wait_background` consumes it even on the + // null-mgr path (P2 #11 fix), so we must not call into + // Rust without a live manager — the box would leak. + // Best we can do is null our handle so the caller doesn't + // try again, and report the error. + bg.handle = nil + return nil, fmt.Errorf("BackgroundTasks.Wait: %w (manager shutdown; background tasks abandoned)", ErrCpexInvalidHandle) + } + + var errorsPtr *C.uint8_t + var errorsLen C.int + + rc := C.cpex_wait_background(bg.mgr.handle, bg.handle, &errorsPtr, &errorsLen) + bg.handle = nil // consumed by Rust regardless of rc (per P2 #11 fix) + + if rc != 0 { + // Output pointers are uninitialized on rc != 0 — must NOT + // read them. C.GoBytes(nil, 0) is safe but reading garbage + // errorsPtr / errorsLen is UB. + return nil, errorFromRC(int(rc), "BackgroundTasks.Wait") + } + + errorsBytes := C.GoBytes(unsafe.Pointer(errorsPtr), errorsLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(errorsPtr)), errorsLen) + + var pluginErrors []PluginError + if err := msgpack.Unmarshal(errorsBytes, &pluginErrors); err != nil { + return nil, fmt.Errorf("BackgroundTasks.Wait: error decode failed: %w", err) + } + return pluginErrors, nil +} + +// Close releases the background task handles without waiting. +// Tasks continue running in the Rust tokio runtime. +func (bg *BackgroundTasks) Close() { + if bg.handle == nil { + return + } + C.cpex_free_background(bg.handle) + bg.handle = nil +} + +// Close releases the Rust-owned context table. +func (ct *ContextTable) Close() { + if ct.handle == nil { + return + } + C.cpex_free_context_table(ct.handle) + ct.handle = nil +} diff --git a/go/cpex/manager_test.go b/go/cpex/manager_test.go new file mode 100644 index 00000000..f5b31c5a --- /dev/null +++ b/go/cpex/manager_test.go @@ -0,0 +1,1175 @@ +// Location: ./go/cpex/manager_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tests for the CPEX Go SDK. +// +// These tests run against the real Rust runtime via cgo. The +// libcpex_ffi staticlib must be built before running: +// +// cargo build --release -p cpex-ffi +// go test -v ./... + +package cpex + +import ( + "errors" + "sync" + "testing" + + "github.com/vmihailenco/msgpack/v5" +) + +func TestNewPluginManagerDefault(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if mgr.PluginCount() != 0 { + t.Errorf("expected 0 plugins, got %d", mgr.PluginCount()) + } + + if mgr.HasHooksFor("test_hook") { + t.Error("expected no hooks registered") + } +} + +func TestNewPluginManagerFromYAML(t *testing.T) { + yaml := ` +plugin_settings: + plugin_timeout: 30 +` + mgr, err := NewPluginManager(yaml) + if err != nil { + t.Fatalf("NewPluginManager failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + if mgr.PluginCount() != 0 { + t.Errorf("expected 0 plugins, got %d", mgr.PluginCount()) + } +} + +func TestNewPluginManagerInvalidYAML(t *testing.T) { + _, err := NewPluginManager("not: [valid: yaml: {{}") + if err == nil { + t.Error("expected error for invalid YAML") + } +} + +func TestInvokeByNameNoPlugins(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Invoke with no registered plugins — should return allowed + payload := map[string]any{ + "tool_name": "test_tool", + "user": "alice", + } + + ext := &Extensions{ + Meta: &MetaExtension{ + EntityType: "tool", + EntityName: "test_tool", + }, + } + + result, ctxTable, bg, err := mgr.InvokeByName("test_hook", PayloadGeneric, payload, ext, nil) + if err != nil { + t.Fatalf("InvokeByName failed: %v", err) + } + defer ctxTable.Close() + defer bg.Close() + + if result.IsDenied() { + t.Error("expected allowed result with no plugins") + } + + if !result.ContinueProcessing { + t.Error("expected continue_processing=true") + } +} + +func TestInvokeByNameWithContextTableThreading(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + ext := &Extensions{} + + // First invocation — nil context table + result1, ctxTable1, bg1, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, ext, nil) + if err != nil { + t.Fatalf("first invoke failed: %v", err) + } + bg1.Close() + + if result1.IsDenied() { + t.Error("first invoke should be allowed") + } + + // Second invocation — thread context table from first + result2, ctxTable2, bg2, err := mgr.InvokeByName("hook2", PayloadGeneric, payload, ext, ctxTable1) + if err != nil { + t.Fatalf("second invoke failed: %v", err) + } + bg2.Close() + + if result2.IsDenied() { + t.Error("second invoke should be allowed") + } + + ctxTable2.Close() +} + +func TestBackgroundTasksWait(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"test": true} + + result, ctxTable, bg, err := mgr.InvokeByName("test", PayloadGeneric, payload, nil, nil) + if err != nil { + t.Fatalf("invoke failed: %v", err) + } + defer ctxTable.Close() + + _ = result + + // Wait should return with no errors (no plugins to run) + errors, err := bg.Wait() + if err != nil { + t.Errorf("bg.Wait failed: %v", err) + } + if len(errors) > 0 { + t.Errorf("expected no background errors, got: %v", errors) + } +} + +// Concurrent goroutines invoking against a single manager must be safe +// (validates the P0 #1 aliased-&mut fix and the Pass 2 RWMutex). Run +// under -race to surface any data races on the handle. +func TestConcurrentInvokesAreSafe(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + const goroutines = 32 + const callsPerGoroutine = 16 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < callsPerGoroutine; j++ { + payload := map[string]any{"i": i, "j": j} + _, ct, bg, err := mgr.InvokeByName("noop", PayloadGeneric, payload, nil, nil) + if err != nil { + t.Errorf("invoke failed: %v", err) + return + } + if ct != nil { + ct.Close() + } + if bg != nil { + _, _ = bg.Wait() + } + } + }() + } + wg.Wait() +} + +// Calling Shutdown while goroutines are mid-invoke must not double-free +// or panic. After Shutdown, in-flight invokes should observe that the +// manager is shutdown and return an error gracefully. +func TestShutdownDuringInvokesIsSafe(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + + // Spawn workers that invoke in a tight loop until they observe shutdown. + var wg sync.WaitGroup + stop := make(chan struct{}) + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + payload := map[string]any{"x": 1} + _, ct, bg, err := mgr.InvokeByName("noop", PayloadGeneric, payload, nil, nil) + if err != nil { + // Expected once Shutdown lands; just stop. + return + } + if ct != nil { + ct.Close() + } + if bg != nil { + _, _ = bg.Wait() + } + } + }() + } + + // Let them spin for a moment, then shutdown. + mgr.Shutdown() + close(stop) + wg.Wait() + + // Second Shutdown must be a no-op (P1 #4 fix — finalizer cleared, + // double-call returns immediately). + mgr.Shutdown() +} + +// BackgroundTasks.Wait() called after the manager has been Shutdown +// must return ErrCpexInvalidHandle without crashing or reading +// uninitialized output pointers. Direct regression for the P1 #3 + +// P1 #5 fix path where bg holds a *PluginManager and checks handle +// nullness under the manager's RWMutex. +func TestBackgroundTasksWaitAfterShutdownReturnsTypedError(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, _, bg, err := mgr.InvokeByName("noop", PayloadGeneric, map[string]any{}, nil, nil) + if err != nil { + t.Fatalf("InvokeByName failed: %v", err) + } + + // Tear the manager down BEFORE waiting on the background tasks. + mgr.Shutdown() + + // Wait must observe the shutdown handle and return a typed error + // — not panic, not segfault on a stale C pointer, not silently + // return empty. + results, err := bg.Wait() + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle, got %v", err) + } + if results != nil { + t.Errorf("expected nil results on error path, got %v", results) + } +} + +// Invoking with an unknown payload_type discriminator must return an +// error wrapping ErrCpexParse — the deserialize_payload registry +// rejects unknown values with RC_PARSE_ERROR, which the Go side +// classifies via errorFromRC. +func TestInvokeUnknownPayloadTypeReturnsParseError(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + const unknownType uint8 = 99 // not in the FFI payload registry + _, _, _, err = mgr.InvokeByName("test_hook", unknownType, map[string]any{}, nil, nil) + if !errors.Is(err, ErrCpexParse) { + t.Errorf("expected ErrCpexParse for unknown payload_type, got %v", err) + } +} + +// IsInitialized reports manager lifecycle accurately: false until +// Initialize is called, true after, false again after Shutdown. +// Validates agent-native gap #4 — introspection FFI. +func TestIsInitializedTracksLifecycle(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + + if mgr.IsInitialized() { + t.Error("expected IsInitialized=false before Initialize") + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + if !mgr.IsInitialized() { + t.Error("expected IsInitialized=true after Initialize") + } + + mgr.Shutdown() + if mgr.IsInitialized() { + t.Error("expected IsInitialized=false after Shutdown") + } +} + +// PluginNames returns the names of plugins registered via YAML config. +// Validates agent-native gap #4 — introspection FFI. +func TestPluginNamesEmptyByDefault(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + names, err := mgr.PluginNames() + if err != nil { + t.Fatalf("PluginNames failed: %v", err) + } + if len(names) != 0 { + t.Errorf("expected empty plugin names on a default manager, got %v", names) + } +} + +// BackgroundTasks.Wait returns []PluginError (structured) — gap #3. +// On a no-plugin invoke the slice is empty but non-nil-shaped. +func TestBackgroundTasksWaitReturnsStructuredErrors(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, _, bg, err := mgr.InvokeByName("test", PayloadGeneric, map[string]any{}, nil, nil) + if err != nil { + t.Fatalf("Invoke failed: %v", err) + } + + errs, err := bg.Wait() + if err != nil { + t.Errorf("Wait failed: %v", err) + } + // errs is []PluginError — typed at compile time. Empty for a + // no-plugin manager, but the structured type is what we wanted. + if len(errs) != 0 { + t.Errorf("expected no errors on no-plugin invoke, got %d: %v", len(errs), errs) + } +} + +// Operations on a shutdown manager must return an error wrapping +// ErrCpexInvalidHandle so callers can classify with errors.Is. +// Validates the P2 #18 typed-error mapping end-to-end. +func TestOperationsAfterShutdownReturnTypedError(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + mgr.Shutdown() + + if err := mgr.Initialize(); !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("Initialize after shutdown: expected ErrCpexInvalidHandle, got %v", err) + } + if err := mgr.LoadConfig("plugin_settings: {}"); !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("LoadConfig after shutdown: expected ErrCpexInvalidHandle, got %v", err) + } + _, _, _, err = mgr.InvokeByName("test", PayloadGeneric, map[string]any{}, nil, nil) + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("InvokeByName after shutdown: expected ErrCpexInvalidHandle, got %v", err) + } +} + +func TestPluginManagerDoubleShutdown(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + + mgr.Shutdown() + // Second shutdown should not panic + mgr.Shutdown() +} + +func TestContextTableDoubleClose(t *testing.T) { + ct := &ContextTable{} + ct.Close() // should not panic + ct.Close() // should not panic +} + +func TestBackgroundTasksDoubleClose(t *testing.T) { + bg := &BackgroundTasks{} + bg.Close() // should not panic + bg.Close() // should not panic +} + +func TestPipelineResultIsDenied(t *testing.T) { + allowed := PipelineResult{ContinueProcessing: true} + if allowed.IsDenied() { + t.Error("expected not denied") + } + + denied := PipelineResult{ + ContinueProcessing: false, + Violation: &PluginViolation{ + Code: "test_denied", + Reason: "test reason", + }, + } + if !denied.IsDenied() { + t.Error("expected denied") + } +} + +func TestExtensionsSerialization(t *testing.T) { + ext := Extensions{ + Meta: &MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + Security: &SecurityExtension{ + Labels: []string{"PII"}, + Classification: "confidential", + Agent: &AgentIdentity{ + ClientID: "hr-agent", + WorkloadID: "spiffe://corp.com/hr-agent", + TrustDomain: "corp.com", + }, + }, + Http: &HttpExtension{ + RequestHeaders: map[string]string{ + "Authorization": "Bearer tok", + "X-Request-ID": "req-123", + }, + }, + } + + // Verify it can be marshaled without error + _, err := msgpackMarshal(ext) + if err != nil { + t.Fatalf("extensions marshal failed: %v", err) + } +} + +// msgpackMarshal is a helper that imports msgpack for the test +func msgpackMarshal(v any) ([]byte, error) { + return msgpack.Marshal(v) +} + +// --------------------------------------------------------------------------- +// Typed Invoke Tests +// --------------------------------------------------------------------------- + +func TestInvokeTypedGenericPayload(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{ + "tool_name": "test_tool", + "user": "alice", + } + + result, ct, bg, err := Invoke[map[string]any]( + mgr, "test_hook", PayloadGeneric, payload, &Extensions{}, nil, + ) + if err != nil { + t.Fatalf("Invoke failed: %v", err) + } + defer ct.Close() + defer bg.Close() + + if result.IsDenied() { + t.Error("expected allowed result") + } + + if !result.ContinueProcessing { + t.Error("expected continue_processing=true") + } +} + +func TestInvokeTypedCMFPayload(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + msg := MessagePayload{ + Message: NewMessage("assistant", + NewTextPart("Looking up compensation data"), + NewToolCallPart(ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + }), + ), + } + + ext := &Extensions{ + Meta: &MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii"}, + }, + } + + result, ct, bg, err := Invoke[MessagePayload]( + mgr, "cmf.tool_pre_invoke", PayloadCMFMessage, msg, ext, nil, + ) + if err != nil { + t.Fatalf("Invoke failed: %v", err) + } + defer ct.Close() + defer bg.Close() + + if result.IsDenied() { + t.Error("expected allowed with no plugins") + } +} + +func TestInvokeTypedContextThreading(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + + // First call — nil context table + r1, ct1, bg1, err := Invoke[map[string]any]( + mgr, "hook1", PayloadGeneric, payload, &Extensions{}, nil, + ) + if err != nil { + t.Fatalf("first invoke failed: %v", err) + } + bg1.Close() + + if r1.IsDenied() { + t.Error("first invoke should be allowed") + } + + // Second call — thread context table + r2, ct2, bg2, err := Invoke[map[string]any]( + mgr, "hook2", PayloadGeneric, payload, &Extensions{}, ct1, + ) + if err != nil { + t.Fatalf("second invoke failed: %v", err) + } + bg2.Close() + + if r2.IsDenied() { + t.Error("second invoke should be allowed") + } + + ct2.Close() +} + +func TestTypedPipelineResultIsDenied(t *testing.T) { + allowed := TypedPipelineResult[map[string]any]{ContinueProcessing: true} + if allowed.IsDenied() { + t.Error("expected not denied") + } + + denied := TypedPipelineResult[map[string]any]{ + ContinueProcessing: false, + Violation: &PluginViolation{ + Code: "test", + Reason: "denied", + }, + } + if !denied.IsDenied() { + t.Error("expected denied") + } +} + +// --------------------------------------------------------------------------- +// CMF Content Part Tests +// --------------------------------------------------------------------------- + +func TestContentPartTextRoundTrip(t *testing.T) { + part := NewTextPart("hello world") + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "text" { + t.Errorf("expected content_type=text, got %s", decoded.ContentType) + } + if decoded.Text != "hello world" { + t.Errorf("expected text='hello world', got '%s'", decoded.Text) + } +} + +func TestContentPartThinkingRoundTrip(t *testing.T) { + part := NewThinkingPart("let me analyze...") + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "thinking" { + t.Errorf("expected content_type=thinking, got %s", decoded.ContentType) + } + if decoded.Text != "let me analyze..." { + t.Errorf("expected thinking text, got '%s'", decoded.Text) + } +} + +func TestContentPartToolCallRoundTrip(t *testing.T) { + part := NewToolCallPart(ToolCall{ + ToolCallID: "tc_001", + Name: "get_weather", + Arguments: map[string]any{"city": "London"}, + Namespace: "tools", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "tool_call" { + t.Errorf("expected content_type=tool_call, got %s", decoded.ContentType) + } + if decoded.ToolCallContent == nil { + t.Fatal("expected ToolCallContent to be set") + } + if decoded.ToolCallContent.Name != "get_weather" { + t.Errorf("expected name=get_weather, got %s", decoded.ToolCallContent.Name) + } + if decoded.ToolCallContent.ToolCallID != "tc_001" { + t.Errorf("expected tool_call_id=tc_001, got %s", decoded.ToolCallContent.ToolCallID) + } + if decoded.ToolCallContent.Namespace != "tools" { + t.Errorf("expected namespace=tools, got %s", decoded.ToolCallContent.Namespace) + } +} + +func TestContentPartToolResultRoundTrip(t *testing.T) { + part := NewToolResultPart(ToolResult{ + ToolCallID: "tc_001", + ToolName: "get_weather", + Content: map[string]any{"temp": 20, "unit": "C"}, + IsError: false, + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "tool_result" { + t.Errorf("expected content_type=tool_result, got %s", decoded.ContentType) + } + if decoded.ToolResultContent == nil { + t.Fatal("expected ToolResultContent to be set") + } + if decoded.ToolResultContent.ToolName != "get_weather" { + t.Errorf("expected tool_name=get_weather, got %s", decoded.ToolResultContent.ToolName) + } +} + +func TestContentPartResourceRoundTrip(t *testing.T) { + part := NewResourcePart(Resource{ + ResourceRequestID: "rr_001", + URI: "file:///data.txt", + ResourceType: "file", + Content: "Hello from file", + MimeType: "text/plain", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "resource" { + t.Errorf("expected content_type=resource, got %s", decoded.ContentType) + } + if decoded.ResourceContent == nil { + t.Fatal("expected ResourceContent to be set") + } + if decoded.ResourceContent.URI != "file:///data.txt" { + t.Errorf("expected uri=file:///data.txt, got %s", decoded.ResourceContent.URI) + } + if decoded.ResourceContent.Content != "Hello from file" { + t.Errorf("expected content='Hello from file', got '%s'", decoded.ResourceContent.Content) + } +} + +func TestContentPartImageRoundTrip(t *testing.T) { + part := NewImagePart(ImageSource{ + SourceType: "url", + Data: "https://example.com/photo.jpg", + MediaType: "image/jpeg", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "image" { + t.Errorf("expected content_type=image, got %s", decoded.ContentType) + } + if decoded.ImageContent == nil { + t.Fatal("expected ImageContent to be set") + } + if decoded.ImageContent.SourceType != "url" { + t.Errorf("expected type=url, got %s", decoded.ImageContent.SourceType) + } + if decoded.ImageContent.Data != "https://example.com/photo.jpg" { + t.Errorf("expected data URL, got %s", decoded.ImageContent.Data) + } +} + +func TestContentPartDocumentRoundTrip(t *testing.T) { + part := NewDocumentPart(DocumentSource{ + SourceType: "base64", + Data: "dGVzdA==", + MediaType: "application/pdf", + Title: "Quarterly Report", + }) + + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.ContentType != "document" { + t.Errorf("expected content_type=document, got %s", decoded.ContentType) + } + if decoded.DocumentContent == nil { + t.Fatal("expected DocumentContent to be set") + } + if decoded.DocumentContent.Title != "Quarterly Report" { + t.Errorf("expected title='Quarterly Report', got '%s'", decoded.DocumentContent.Title) + } +} + +// Regression for P2 #13 — `decodeVideoSource`/`decodeAudioSource` +// previously dropped DurationMs. With the generic decodeAs[T] helper +// driven by msgpack tags, fields can no longer be silently lost. +func TestContentPartVideoRoundTripWithDuration(t *testing.T) { + dur := uint64(15000) + part := NewVideoPart(VideoSource{ + SourceType: "url", + Data: "https://example.com/v.mp4", + MediaType: "video/mp4", + DurationMs: &dur, + }) + data, err := msgpack.Marshal(part) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.VideoContent == nil { + t.Fatal("VideoContent nil") + } + if decoded.VideoContent.DurationMs == nil || *decoded.VideoContent.DurationMs != 15000 { + t.Errorf("DurationMs lost: %v", decoded.VideoContent.DurationMs) + } +} + +// Regression for P2 #13 — `decodeResource` previously dropped Blob +// and SizeBytes; `decodeResourceRef` dropped RangeStart and RangeEnd. +func TestContentPartResourceFieldsPreserved(t *testing.T) { + size := uint64(2048) + rstart := uint64(100) + rend := uint64(500) + + resource := NewResourcePart(Resource{ + ResourceRequestID: "rr_1", + URI: "file:///doc.bin", + ResourceType: "binary", + Blob: []byte{0xDE, 0xAD, 0xBE, 0xEF}, + SizeBytes: &size, + MimeType: "application/octet-stream", + }) + data, err := msgpack.Marshal(resource) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var d1 ContentPart + if err := msgpack.Unmarshal(data, &d1); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if d1.ResourceContent == nil { + t.Fatal("ResourceContent nil") + } + if string(d1.ResourceContent.Blob) != "\xDE\xAD\xBE\xEF" { + t.Errorf("Blob lost: %v", d1.ResourceContent.Blob) + } + if d1.ResourceContent.SizeBytes == nil || *d1.ResourceContent.SizeBytes != 2048 { + t.Errorf("SizeBytes lost: %v", d1.ResourceContent.SizeBytes) + } + + ref := NewResourceRefPart(ResourceReference{ + ResourceRequestID: "rr_2", + URI: "file:///doc.bin", + ResourceType: "binary", + RangeStart: &rstart, + RangeEnd: &rend, + }) + data, err = msgpack.Marshal(ref) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var d2 ContentPart + if err := msgpack.Unmarshal(data, &d2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if d2.ResourceRefContent == nil { + t.Fatal("ResourceRefContent nil") + } + if d2.ResourceRefContent.RangeStart == nil || *d2.ResourceRefContent.RangeStart != 100 { + t.Errorf("RangeStart lost: %v", d2.ResourceRefContent.RangeStart) + } + if d2.ResourceRefContent.RangeEnd == nil || *d2.ResourceRefContent.RangeEnd != 500 { + t.Errorf("RangeEnd lost: %v", d2.ResourceRefContent.RangeEnd) + } +} + +// Regression for P2 #13 — `decodePromptResult` previously dropped +// the Messages field entirely (with a "TODO: nested decode" comment). +// The generic helper handles it correctly, including nested Messages +// with their own ContentPart custom decoder. +func TestContentPartPromptResultPreservesMessages(t *testing.T) { + pr := NewPromptResultPart(PromptResult{ + PromptRequestID: "pr_1", + PromptName: "summarize", + Messages: []Message{ + NewMessage("system", NewTextPart("You are concise.")), + NewMessage("user", NewTextPart("Summarize the report.")), + }, + }) + data, err := msgpack.Marshal(pr) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded ContentPart + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.PromptResultContent == nil { + t.Fatal("PromptResultContent nil") + } + if len(decoded.PromptResultContent.Messages) != 2 { + t.Fatalf("expected 2 nested messages, got %d", len(decoded.PromptResultContent.Messages)) + } + if decoded.PromptResultContent.Messages[0].Role != "system" { + t.Errorf("first nested role lost: %s", decoded.PromptResultContent.Messages[0].Role) + } + if len(decoded.PromptResultContent.Messages[1].Content) != 1 || + decoded.PromptResultContent.Messages[1].Content[0].Text != "Summarize the report." { + t.Errorf("nested content lost: %+v", decoded.PromptResultContent.Messages[1].Content) + } +} + +// Regression for P2 #17 — unknown content_type variants previously +// decoded to an empty ContentPart and re-encoded as a text fallback, +// silently dropping the original payload. Now the raw map is captured +// on decode and emitted verbatim on encode, so a future variant from +// Rust passes through an older Go SDK without data loss. +func TestContentPartUnknownContentTypeRoundTrip(t *testing.T) { + // Simulate a future Rust variant by encoding a map directly. + original := map[string]any{ + "content_type": "future_variant", + "content": map[string]any{ + "new_field": "value", + "count": uint64(42), + }, + } + wire, err := msgpack.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // Decode through ContentPart's custom decoder, then re-encode. + var cp ContentPart + if err := msgpack.Unmarshal(wire, &cp); err != nil { + t.Fatalf("decode: %v", err) + } + if cp.ContentType != "future_variant" { + t.Errorf("ContentType lost: %s", cp.ContentType) + } + roundtripped, err := msgpack.Marshal(cp) + if err != nil { + t.Fatalf("re-encode: %v", err) + } + + // Decode the roundtripped wire as a plain map and verify the + // new_field is still there. + var back map[string]any + if err := msgpack.Unmarshal(roundtripped, &back); err != nil { + t.Fatalf("unmarshal back: %v", err) + } + contentMap, ok := back["content"].(map[string]any) + if !ok { + t.Fatalf("content field missing or wrong type after roundtrip: %#v", back) + } + if contentMap["new_field"] != "value" { + t.Errorf("new_field lost across roundtrip: %#v", contentMap) + } +} + +// Regression for P2 #14, #15, #16 — Extension fields that Rust +// serializes but Go was silently dropping. Round-trip a populated +// Extensions through msgpack and verify each field survives. +func TestExtensionsAddedFieldsRoundTrip(t *testing.T) { + turn := uint32(7) + ext := &Extensions{ + Agent: &AgentExtension{ + SessionID: "sess_1", + ConversationID: "conv_1", + Turn: &turn, + AgentID: "agent_1", + Conversation: &ConversationContext{ + History: []any{"prior turn"}, + Summary: "user asked for compensation lookup", + Topics: []string{"hr", "compensation"}, + }, + }, + MCP: &MCPExtension{ + Tool: &ToolMetadata{ + Name: "get_compensation", + OutputSchema: map[string]any{"type": "object"}, + Annotations: map[string]any{"audit_required": true}, + }, + }, + Completion: &CompletionExtension{ + Model: "claude-sonnet-4-6", + RawFormat: "anthropic", + CreatedAt: "2026-05-04T10:00:00Z", + }, + } + + data, err := msgpack.Marshal(ext) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back Extensions + if err := msgpack.Unmarshal(data, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if back.Agent == nil || back.Agent.Conversation == nil { + t.Fatal("Agent.Conversation lost") + } + if back.Agent.Conversation.Summary != "user asked for compensation lookup" { + t.Errorf("Conversation.Summary lost: %q", back.Agent.Conversation.Summary) + } + if len(back.Agent.Conversation.Topics) != 2 { + t.Errorf("Conversation.Topics lost: %v", back.Agent.Conversation.Topics) + } + if back.Agent.Turn == nil || *back.Agent.Turn != 7 { + t.Errorf("Turn lost or wrong type: %v", back.Agent.Turn) + } + + if back.MCP == nil || back.MCP.Tool == nil { + t.Fatal("MCP.Tool lost") + } + if back.MCP.Tool.OutputSchema == nil { + t.Error("Tool.OutputSchema lost") + } + if back.MCP.Tool.Annotations == nil { + t.Error("Tool.Annotations lost") + } + + if back.Completion == nil { + t.Fatal("Completion lost") + } + if back.Completion.RawFormat != "anthropic" { + t.Errorf("Completion.RawFormat lost: %q", back.Completion.RawFormat) + } + if back.Completion.CreatedAt != "2026-05-04T10:00:00Z" { + t.Errorf("Completion.CreatedAt lost: %q", back.Completion.CreatedAt) + } +} + +func TestMessagePayloadSerialization(t *testing.T) { + msg := MessagePayload{ + Message: NewMessage("assistant", + NewTextPart("I'll look that up for you."), + NewToolCallPart(ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + }), + ), + } + + data, err := msgpack.Marshal(msg) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + if len(data) == 0 { + t.Fatal("expected non-empty msgpack bytes") + } + + // Verify it round-trips as a generic map (to check wire format) + var raw map[string]any + if err := msgpack.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal to map failed: %v", err) + } + + message, ok := raw["message"].(map[string]any) + if !ok { + t.Fatal("expected 'message' key in payload") + } + + if message["schema_version"] != "2.0" { + t.Errorf("expected schema_version=2.0, got %v", message["schema_version"]) + } + + if message["role"] != "assistant" { + t.Errorf("expected role=assistant, got %v", message["role"]) + } + + content, ok := message["content"].([]any) + if !ok { + t.Fatal("expected content to be a list") + } + + if len(content) != 2 { + t.Fatalf("expected 2 content parts, got %d", len(content)) + } + + // First part should be text + part0, ok := content[0].(map[string]any) + if !ok { + t.Fatal("expected content[0] to be a map") + } + if part0["content_type"] != "text" { + t.Errorf("expected content_type=text, got %v", part0["content_type"]) + } + + // Second part should be tool_call + part1, ok := content[1].(map[string]any) + if !ok { + t.Fatal("expected content[1] to be a map") + } + if part1["content_type"] != "tool_call" { + t.Errorf("expected content_type=tool_call, got %v", part1["content_type"]) + } +} + +func TestLoadConfigOnDefaultManager(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + // LoadConfig with valid YAML (no plugins, just settings) + err = mgr.LoadConfig(` +plugin_settings: + plugin_timeout: 30 +`) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } +} + +func TestLoadConfigInvalidYAML(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + err = mgr.LoadConfig("not: [valid: yaml: {{}") + if err == nil { + t.Error("expected error for invalid YAML") + } +} diff --git a/go/cpex/types.go b/go/cpex/types.go new file mode 100644 index 00000000..04cd0adc --- /dev/null +++ b/go/cpex/types.go @@ -0,0 +1,318 @@ +// Location: ./go/cpex/types.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Go types — extensions, pipeline results, and payload constants. +// +// All types use msgpack struct tags matching the Rust field names +// for zero-copy serialization across the FFI boundary. Extension +// types mirror crates/cpex-core/src/extensions/. + +package cpex + +import "github.com/vmihailenco/msgpack/v5" + +// Extensions carries capability-gated data alongside the payload. +// Serialized to/from MessagePack when crossing the FFI boundary. +type Extensions struct { + Meta *MetaExtension `msgpack:"meta,omitempty"` + Security *SecurityExtension `msgpack:"security,omitempty"` + Http *HttpExtension `msgpack:"http,omitempty"` + Delegation *DelegationExtension `msgpack:"delegation,omitempty"` + Agent *AgentExtension `msgpack:"agent,omitempty"` + Request *RequestExtension `msgpack:"request,omitempty"` + MCP *MCPExtension `msgpack:"mcp,omitempty"` + Completion *CompletionExtension `msgpack:"completion,omitempty"` + Provenance *ProvenanceExtension `msgpack:"provenance,omitempty"` + LLM *LLMExtension `msgpack:"llm,omitempty"` + Framework *FrameworkExtension `msgpack:"framework,omitempty"` + Custom map[string]any `msgpack:"custom,omitempty"` +} + +// MetaExtension carries entity identification for route resolution. +type MetaExtension struct { + EntityType string `msgpack:"entity_type,omitempty"` + EntityName string `msgpack:"entity_name,omitempty"` + Tags []string `msgpack:"tags,omitempty"` + Scope string `msgpack:"scope,omitempty"` + Properties map[string]string `msgpack:"properties,omitempty"` +} + +// SecurityExtension carries identity, labels, and data policies. +type SecurityExtension struct { + Labels []string `msgpack:"labels,omitempty"` + Classification string `msgpack:"classification,omitempty"` + Subject *SubjectExtension `msgpack:"subject,omitempty"` + Agent *AgentIdentity `msgpack:"agent,omitempty"` + AuthMethod string `msgpack:"auth_method,omitempty"` + Objects map[string]ObjectSecurityProfile `msgpack:"objects,omitempty"` + Data map[string]DataPolicy `msgpack:"data,omitempty"` +} + +// SubjectExtension represents the authenticated caller. +type SubjectExtension struct { + ID string `msgpack:"id,omitempty"` + SubjectType string `msgpack:"subject_type,omitempty"` + Roles []string `msgpack:"roles,omitempty"` + Permissions []string `msgpack:"permissions,omitempty"` + Teams []string `msgpack:"teams,omitempty"` + Claims map[string]string `msgpack:"claims,omitempty"` +} + +// AgentIdentity represents this agent's own workload identity. +type AgentIdentity struct { + ClientID string `msgpack:"client_id,omitempty"` + WorkloadID string `msgpack:"workload_id,omitempty"` + TrustDomain string `msgpack:"trust_domain,omitempty"` +} + +// ObjectSecurityProfile is a security profile for a managed object. +type ObjectSecurityProfile struct { + ManagedBy string `msgpack:"managed_by,omitempty"` + Permissions []string `msgpack:"permissions,omitempty"` + TrustDomain string `msgpack:"trust_domain,omitempty"` + DataScope []string `msgpack:"data_scope,omitempty"` +} + +// DataPolicy defines data handling policies. +type DataPolicy struct { + ApplyLabels []string `msgpack:"apply_labels,omitempty"` + AllowedActions []string `msgpack:"allowed_actions,omitempty"` + DeniedActions []string `msgpack:"denied_actions,omitempty"` + Retention *RetentionPolicy `msgpack:"retention,omitempty"` +} + +// RetentionPolicy defines data retention rules. +type RetentionPolicy struct { + MaxAgeSeconds *uint64 `msgpack:"max_age_seconds,omitempty"` + Policy string `msgpack:"policy,omitempty"` + DeleteAfter string `msgpack:"delete_after,omitempty"` +} + +// HttpExtension carries HTTP request and response headers. +type HttpExtension struct { + RequestHeaders map[string]string `msgpack:"request_headers,omitempty"` + ResponseHeaders map[string]string `msgpack:"response_headers,omitempty"` +} + +// DelegationExtension carries the token delegation chain. +type DelegationExtension struct { + Chain []DelegationHop `msgpack:"chain,omitempty"` + Depth int `msgpack:"depth,omitempty"` + OriginSubjectID string `msgpack:"origin_subject_id,omitempty"` + ActorSubjectID string `msgpack:"actor_subject_id,omitempty"` + Delegated bool `msgpack:"delegated,omitempty"` + AgeSeconds float64 `msgpack:"age_seconds,omitempty"` +} + +// DelegationHop is a single step in the delegation chain. +type DelegationHop struct { + SubjectID string `msgpack:"subject_id,omitempty"` + SubjectType string `msgpack:"subject_type,omitempty"` + Audience string `msgpack:"audience,omitempty"` + ScopesGranted []string `msgpack:"scopes_granted,omitempty"` + Timestamp string `msgpack:"timestamp,omitempty"` + TTLSeconds *uint64 `msgpack:"ttl_seconds,omitempty"` + Strategy string `msgpack:"strategy,omitempty"` + FromCache bool `msgpack:"from_cache,omitempty"` +} + +// AgentExtension carries agent execution context. +type AgentExtension struct { + Input string `msgpack:"input,omitempty"` + SessionID string `msgpack:"session_id,omitempty"` + ConversationID string `msgpack:"conversation_id,omitempty"` + // Turn is *uint32 to match Rust's Option. Previously *int (64-bit + // in Go) — values >2^32 would overflow the Rust side silently. + Turn *uint32 `msgpack:"turn,omitempty"` + AgentID string `msgpack:"agent_id,omitempty"` + ParentAgentID string `msgpack:"parent_agent_id,omitempty"` + // Conversation mirrors Rust's `conversation: Option`. + // Previously absent — Rust serialized this field but Go silently dropped + // it (P2 #16). + Conversation *ConversationContext `msgpack:"conversation,omitempty"` +} + +// ConversationContext is per-conversation summary state, shared across +// turns. Mirrors `cpex_core::extensions::agent::ConversationContext`. +type ConversationContext struct { + // Recent conversation history, lightweight summaries (free-form + // JSON-style values to match Rust's Vec). + History []any `msgpack:"history,omitempty"` + // LLM-generated conversation summary. + Summary string `msgpack:"summary,omitempty"` + // Detected topics for routing / classification. + Topics []string `msgpack:"topics,omitempty"` +} + +// RequestExtension carries execution environment and tracing. +type RequestExtension struct { + Environment string `msgpack:"environment,omitempty"` + RequestID string `msgpack:"request_id,omitempty"` + Timestamp string `msgpack:"timestamp,omitempty"` + TraceID string `msgpack:"trace_id,omitempty"` + SpanID string `msgpack:"span_id,omitempty"` +} + +// MCPExtension carries MCP entity metadata. +type MCPExtension struct { + Tool *ToolMetadata `msgpack:"tool,omitempty"` + Resource *ResourceMetadata `msgpack:"resource,omitempty"` + Prompt *PromptMetadata `msgpack:"prompt,omitempty"` +} + +// ToolMetadata is MCP tool metadata. +type ToolMetadata struct { + Name string `msgpack:"name"` + Title string `msgpack:"title,omitempty"` + Description string `msgpack:"description,omitempty"` + InputSchema map[string]any `msgpack:"input_schema,omitempty"` + // OutputSchema and Annotations were missing — Rust serialized them, + // Go silently dropped them (P2 #15). + OutputSchema map[string]any `msgpack:"output_schema,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` + Namespace string `msgpack:"namespace,omitempty"` + Annotations map[string]any `msgpack:"annotations,omitempty"` +} + +// ResourceMetadata is MCP resource metadata. +type ResourceMetadata struct { + URI string `msgpack:"uri"` + Name string `msgpack:"name,omitempty"` + Description string `msgpack:"description,omitempty"` + MimeType string `msgpack:"mime_type,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` +} + +// PromptMetadata is MCP prompt metadata. +type PromptMetadata struct { + Name string `msgpack:"name"` + Description string `msgpack:"description,omitempty"` + ServerID string `msgpack:"server_id,omitempty"` +} + +// CompletionExtension carries LLM completion information. +type CompletionExtension struct { + StopReason string `msgpack:"stop_reason,omitempty"` + Tokens *TokenUsage `msgpack:"tokens,omitempty"` + Model string `msgpack:"model,omitempty"` + // RawFormat and CreatedAt were missing — Rust serialized them, + // Go silently dropped them (P2 #14). + RawFormat string `msgpack:"raw_format,omitempty"` + CreatedAt string `msgpack:"created_at,omitempty"` + LatencyMs *uint64 `msgpack:"latency_ms,omitempty"` +} + +// TokenUsage is token usage statistics. +type TokenUsage struct { + InputTokens int `msgpack:"input_tokens,omitempty"` + OutputTokens int `msgpack:"output_tokens,omitempty"` + TotalTokens int `msgpack:"total_tokens,omitempty"` +} + +// ProvenanceExtension carries origin and message threading. +type ProvenanceExtension struct { + Source string `msgpack:"source,omitempty"` + MessageID string `msgpack:"message_id,omitempty"` + ParentID string `msgpack:"parent_id,omitempty"` +} + +// LLMExtension carries model identity and capabilities. +type LLMExtension struct { + ModelID string `msgpack:"model_id,omitempty"` + Provider string `msgpack:"provider,omitempty"` + Capabilities []string `msgpack:"capabilities,omitempty"` +} + +// FrameworkExtension carries agentic framework context. +type FrameworkExtension struct { + Framework string `msgpack:"framework,omitempty"` + FrameworkVersion string `msgpack:"framework_version,omitempty"` + NodeID string `msgpack:"node_id,omitempty"` + GraphID string `msgpack:"graph_id,omitempty"` + Metadata map[string]any `msgpack:"metadata,omitempty"` +} + +// PluginViolation is a structured policy denial. +type PluginViolation struct { + Code string `msgpack:"code"` + Reason string `msgpack:"reason"` + Description string `msgpack:"description,omitempty"` + Details map[string]any `msgpack:"details,omitempty"` + PluginName string `msgpack:"plugin_name,omitempty"` + ProtoErrorCode *int64 `msgpack:"proto_error_code,omitempty"` +} + +// PluginError is a plugin execution error. +type PluginError struct { + PluginName string `msgpack:"plugin_name"` + Message string `msgpack:"message"` + Code string `msgpack:"code,omitempty"` + Details map[string]any `msgpack:"details,omitempty"` + ProtoErrorCode *int64 `msgpack:"proto_error_code,omitempty"` +} + +// PipelineResult is the aggregate result from a hook invocation. +type PipelineResult struct { + ContinueProcessing bool `msgpack:"continue_processing"` + Violation *PluginViolation `msgpack:"violation,omitempty"` + // Errors from plugins that ran with on_error: ignore or + // on_error: disable. Empty when no plugin errored on a non-halt + // path. Fire-and-forget errors live on BackgroundTasks.Wait() + // instead. + Errors []PluginError `msgpack:"errors,omitempty"` + Metadata map[string]any `msgpack:"metadata,omitempty"` + // Payload type ID — tells the caller how to deserialize ModifiedPayload. + PayloadType uint8 `msgpack:"payload_type"` + // Modified payload as raw MessagePack bytes. + ModifiedPayload []byte `msgpack:"modified_payload,omitempty"` + // Modified extensions as raw MessagePack bytes. + ModifiedExtensions []byte `msgpack:"modified_extensions,omitempty"` +} + +// TypedPipelineResult is a PipelineResult with the modified payload +// and extensions deserialized into concrete Go types. +type TypedPipelineResult[P any] struct { + ContinueProcessing bool + Violation *PluginViolation + Errors []PluginError + Metadata map[string]any + PayloadType uint8 + ModifiedPayload *P + ModifiedExtensions *Extensions +} + +// IsDenied returns true if the pipeline was halted by a plugin. +func (r *TypedPipelineResult[P]) IsDenied() bool { + return !r.ContinueProcessing +} + +// DeserializePayload deserializes the modified payload into a typed struct. +func DeserializePayload[T any](result *PipelineResult) (*T, error) { + if len(result.ModifiedPayload) == 0 { + return nil, nil + } + var v T + if err := msgpack.Unmarshal(result.ModifiedPayload, &v); err != nil { + return nil, err + } + return &v, nil +} + +// DeserializeExtensions deserializes the modified extensions. +func (r *PipelineResult) DeserializeExtensions() (*Extensions, error) { + if len(r.ModifiedExtensions) == 0 { + return nil, nil + } + var ext Extensions + if err := msgpack.Unmarshal(r.ModifiedExtensions, &ext); err != nil { + return nil, err + } + return &ext, nil +} + +// IsDenied returns true if the pipeline was halted by a plugin. +func (r *PipelineResult) IsDenied() bool { + return !r.ContinueProcessing +} From 7bf30ca3f95b2af7c5313d615044405990fdd71e Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 11 May 2026 09:23:38 -0600 Subject: [PATCH 05/11] docs: intial rust specification (#50) Co-authored-by: Teryl Taylor --- docs/specs/cpex-rust-spec.md | 1424 ++++++++++++++++++++++++++++++++++ 1 file changed, 1424 insertions(+) create mode 100644 docs/specs/cpex-rust-spec.md diff --git a/docs/specs/cpex-rust-spec.md b/docs/specs/cpex-rust-spec.md new file mode 100644 index 00000000..eeb2be29 --- /dev/null +++ b/docs/specs/cpex-rust-spec.md @@ -0,0 +1,1424 @@ +# CPEX Rust — Public API Specification + +**Status**: Draft +**Date**: May 2026 +**Source**: `crates/cpex-core` in `github.com/contextforge-org/contextforge-plugins-framework` + +CPEX Rust is the core plugin runtime — pure Rust, no FFI/WASM/PyO3 dependencies. It serves two audiences: + +- **Embedders** — Rust hosts that want an in-process plugin pipeline (configure → load → invoke). +- **Plugin authors** — code that runs *inside* the runtime as native Rust plugins (define hooks, write `HookHandler` impls). + +The Go SDK (`go/cpex`, see [cpex-go-spec.md](./cpex-go-spec.md)) is one consumer of cpex-core via cpex-ffi. Other language bindings layer the same way. This spec documents the Rust API directly, with a focus on plugin authoring (§6, §11). + +## 1. Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ Rust Host │ +│ │ +│ PluginManager ───────────────────────────────┐ │ +│ │ PluginManager::new(ManagerConfig) │ │ +│ │ PluginManager::from_config(path, factories)│ │ +│ │ register_handler::(plugin, config) │ │ +│ │ initialize().await │ │ +│ │ invoke::(payload, ext, ct).await │ │ +│ │ invoke_named::(name, payload, ext, ct) │ │ +│ │ has_hooks_for(name) / plugin_count() │ │ +│ │ shutdown().await │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ │ +├────────────────────────┼─────────────────────────────┤ +│ Executor ▼ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 5-Phase Pipeline │ │ +│ │ 1. Sequential — block + modify │ │ +│ │ 2. Transform — modify only │ │ +│ │ 3. Audit — read-only, serial │ │ +│ │ 4. Concurrent — block-only, parallel │ │ +│ │ 5. FireAndForget — background tasks │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ │ +├────────────────────────┼─────────────────────────────┤ +│ Plugins ▼ │ +│ impl Plugin + impl HookHandler │ +│ • Capability-gated extension reads/writes │ +│ • Async lifecycle, sync handle() │ +└──────────────────────────────────────────────────────┘ +``` + +**Key design decisions:** + +- **Typed dispatch is the default.** The recommended API is `invoke::(payload, ...)`, where `H: HookTypeDef` carries the payload type at compile time. The compiler enforces payload/hook compatibility — there's no `Box` in user code on the happy path. +- **Hook types are open** — hosts define their own via the `HookTypeDef` trait or the `define_hook!` macro. cpex-core ships built-ins (`tool_pre_invoke`, CMF hooks) but does not require them. +- **Capabilities at config** — extension visibility and write authority are declared in YAML (or programmatically on `PluginConfig`). The executor enforces them by handing out `WriteToken`s only for declared capabilities. +- **Async-by-default handler.** Both plugin lifecycle (`initialize`, `shutdown`) and the per-invocation `handle(...)` are `async`. Handlers that don't need to await anything compile to a trivially-ready future that LLVM inlines, so there is no cost over a plain function call. Handlers that do need to await just `.await` inside the body. See §6.2 for the cost breakdown and the guidance on when to put `.await` in `handle`. + +## 2. Crate Layout & Dependencies + +```toml +[dependencies] +cpex-core = { git = "https://github.com/contextforge-org/contextforge-plugins-framework", branch = "main" } +async-trait = "0.1" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +``` + +**Module overview** (everything is `pub` unless marked): + +| Module | Purpose | +|---|---| +| `cpex_core::plugin` | `Plugin` trait, `PluginConfig`, `PluginMode`, `OnError`, `PluginCondition`, `MatchContext` | +| `cpex_core::hooks` | `HookTypeDef`, `HookHandler`, `PluginPayload`, `PluginResult

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

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

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

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

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

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

`s into the final `PipelineResult`. + +To read a typed modified payload back from a `PipelineResult`: + +```rust +if let Some(boxed) = result.modified_payload.as_ref() { + if let Some(typed) = boxed.as_any().downcast_ref::() { + // typed: &ToolInvokePayload + } +} +``` + +If you stayed on the typed `invoke::` path, the `H::Payload` you sent in is the type to downcast to — no surprises. + +### 5.9 PluginError + +The framework's error type. All public functions return `Result>`. + +```rust +#[derive(Debug, Error)] +pub enum PluginError { + Execution { + plugin_name: String, + message: String, + source: Option>, + code: Option, + details: HashMap, + proto_error_code: Option, + }, + Timeout { plugin_name: String, timeout_ms: u64, proto_error_code: Option }, + Violation { plugin_name: String, violation: PluginViolation }, + Config { message: String }, + UnknownHook { hook_type: String }, +} + +impl PluginError { + pub fn boxed(self) -> Box; // sugar for Box::new(self) +} +``` + +**Why boxed:** the enum is ~184 bytes (large `details` HashMap, `source` trait object). `Result>` keeps the success path pointer-sized; the allocation only happens on the error path. This is the standard Rust pattern for rich error types and is enforced by `clippy::result_large_err`. + +Construction is ergonomic: + +```rust +return Err(PluginError::Config { + message: "missing policy_file".into(), +}.boxed()); + +// `?` works automatically — From for Box is in std: +let cfg: MyConfig = serde_json::from_value(raw)?; // serde error ↗ Box +``` + +### 5.10 PluginViolation + +Structured denial. Returned by plugins that want to halt the pipeline with a reason. + +```rust +pub struct PluginViolation { + pub code: String, // machine-readable identifier + pub reason: String, // short human-readable explanation + pub description: Option, // longer detail + pub details: HashMap, // structured diagnostic data + pub plugin_name: Option, // set by framework after return + pub proto_error_code: Option, // wire-protocol error code +} + +impl PluginViolation { + pub fn new(code: impl Into, reason: impl Into) -> Self; + pub fn with_description(self, description: impl Into) -> Self; + pub fn with_details(self, details: HashMap) -> Self; + pub fn with_proto_error_code(self, code: i64) -> Self; +} +``` + +### 5.11 PluginErrorRecord + +`Clone`-able snapshot of a `PluginError`. Lives in `PipelineResult.errors`. `PluginError` itself can't be `Clone` (the `source: Box` is not cloneable) and errors crossing the FFI boundary need `Serialize`/`Deserialize`. + +```rust +#[derive(Clone, Serialize, Deserialize)] +pub struct PluginErrorRecord { + pub plugin_name: String, + pub message: String, + pub code: Option, + pub details: HashMap, + pub proto_error_code: Option, +} + +impl From<&PluginError> for PluginErrorRecord { /* ... */ } +impl From<&Box> for PluginErrorRecord { /* forwarder */ } +``` + +The `From<&Box>` forwarder exists so call sites that hold `e: Box` can write `(&e).into()` without a manual deref. + +## 6. Plugin Authoring + +This is the heart of the API for plugin authors. The full minimal plugin is: + +1. Define a payload type and `impl_plugin_payload!` it. +2. Define a hook type implementing `HookTypeDef`. +3. Implement `Plugin` for the plugin struct. +4. Implement `HookHandler` for each hook the plugin handles. +5. Register the plugin (via `register_handler` or a `PluginFactory`). + +§11 walks through this end-to-end. This section explains each piece. + +### 6.1 The `Plugin` Trait + +Every plugin implements `Plugin`. It carries the plugin's config and the lifecycle hooks. + +```rust +#[async_trait] +pub trait Plugin: Send + Sync { + /// The plugin's configuration. Read-only — the framework holds + /// the authoritative copy in `PluginRef.trusted_config`. + fn config(&self) -> &PluginConfig; + + /// One-time initialization. Called before any invokes. + /// Use to open connections, load resources, validate config. + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + /// Graceful shutdown. Called once during teardown. + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} +``` + +Default implementations for `initialize`/`shutdown` are no-ops; override only if your plugin needs them. + +### 6.2 The `HookHandler` Trait + +Each hook the plugin handles requires a separate `HookHandler` impl. The type parameter `H` is the hook type (a marker struct implementing `HookTypeDef`). + +```rust +pub trait HookHandler: Plugin + Send + Sync { + fn handle( + &self, + payload: &H::Payload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> impl std::future::Future + Send; +} +``` + +The `fn ... -> impl Future` shape is **native AFIT** (Associated Fn In Trait, stable since Rust 1.75). Plugin authors write the impl with the more familiar `async fn` form — it desugars to the same thing: + +```rust +impl HookHandler for AllowPlugin { + async fn handle( + &self, + _payload: &MyPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +impl HookHandler for AuthzPlugin { + async fn handle( + &self, + payload: &MyPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + match self.client.check(&payload.user).await { + Ok(true) => PluginResult::allow(), + _ => PluginResult::deny(/* ... */), + } + } +} +``` + +**Borrow semantics:** + +- `payload: &H::Payload` — always a borrow. The executor's mode-aware adapter passes either a clone (Sequential/Transform) or a true reference (Audit/Concurrent/FireAndForget). The plugin sees `&` either way; if it needs to mutate, it `clone()`s and returns `PluginResult::modify_payload(modified)`. +- `extensions: &Extensions` — capability-filtered view. Slots the plugin lacks read capabilities for appear as `None`. +- `ctx: &mut PluginContext` — per-plugin state. Read/write the local state map; stage updates to global state via `ctx.set_global(...)`. + +**Async by design.** `handle` is `async fn`. Plugins that don't need to await anything still write `async fn handle(...)` and return synchronously — the compiler emits a trivially-ready future and LLVM inlines it at the adapter site, so there is no observable runtime cost over a plain function call. Plugins that *do* need to await (fresh JWKS fetch, RPC to authz, dynamic policy lookup) just use `.await` inside the body. + +**Registration is the same for both.** A single `register_handler::` call accepts a plugin whose `handle` body is purely sync as well as one that genuinely awaits — the trait doesn't distinguish. + +```rust +manager.register_handler::(plugin, config)?; +``` + +**Cost:** + +- Plugins with no `.await` in `handle` compile to a `Ready` future that the executor awaits; LLVM typically inlines this to a direct call. No heap allocation, no scheduler interaction. +- Plugins that actually await pay normal async cost (one boxed future at the type-erased `AnyHookHandler` boundary, plus whatever the awaited work costs). Native AFIT is what avoids per-call boxing at the typed layer — `#[async_trait]` would have boxed every call. + +**When to put `.await` in `handle`:** prefer caching at init time and reading from cache on the hot path — that is the most common source of latency regressions in plugins. Only put `.await` in `handle` when caching genuinely won't work (e.g., per-request decisions against authoritative state). + +### 6.3 The `PluginPayload` Trait + +The base trait for all hook payloads. Object-safe — the framework dispatches via `Box` internally, but plugin code rarely sees that directly when using `invoke::`. + +```rust +pub trait PluginPayload: Send + Sync + 'static { + fn clone_boxed(&self) -> Box; + fn as_any(&self) -> &dyn Any; + fn as_any_mut(&mut self) -> &mut dyn Any; +} +``` + +Implement it via the macro: + +```rust +use cpex_core::impl_plugin_payload; + +#[derive(Debug, Clone)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: serde_json::Value, +} +impl_plugin_payload!(ToolInvokePayload); +``` + +The macro expands to the three method impls — saves boilerplate per type. Requirements: the type must be `Clone + Send + Sync + 'static`. No `Serialize` is required by `PluginPayload` itself, but payloads that cross the FFI boundary (and so are deserializable from MessagePack) typically derive `serde::Serialize + Deserialize` too. + +### 6.4 Defining a Hook Type + +A hook type is a zero-sized marker struct that implements `HookTypeDef`. It associates a name (for registry lookup) with a typed payload and result. + +```rust +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; + +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} +``` + +**Conventions:** + +- `type Result = PluginResult` — the standard shape. Custom result types are possible (the trait doesn't require `PluginResult`) but the executor wires `H::Result: Into>` so anything you return must convert into one. +- `NAME` is the lookup key for `register_handler::(...)` and the `hooks: [tool_pre_invoke]` line in YAML. It's also what `invoke::` uses for dispatch — so calling `invoke::` is exactly equivalent to `invoke_by_name(H::NAME, ...)` with the type advantages. +- One marker can be shared across multiple hook *names* if your plugin handles a family. See the CMF pattern in §6.6. + +#### `define_hook!` macro (sugar) + +For the common case, a macro generates the marker struct, the trait impl, and a `HookHandler` shorthand in one declaration: + +```rust +use cpex_core::define_hook; + +define_hook! { + /// Hook for tool_pre_invoke. + ToolPreInvoke; + "tool_pre_invoke" => Payload(ToolInvokePayload) -> Result(PluginResult); +} +``` + +Either form is fine — manual when you want fine control over docs/derives, the macro for less typing. + +### 6.5 PluginResult Constructors + +The four canonical outcomes a plugin signals: + +| Constructor | What it signals | +|---|---| +| `PluginResult::allow()` | Pass. No changes. | +| `PluginResult::deny(violation)` | Halt the pipeline. Caller sees `result.is_denied() == true`. | +| `PluginResult::modify_payload(p)` | Pass. Replace the payload in flight (Sequential/Transform only). | +| `PluginResult::modify_extensions(owned)` | Pass. Apply extension changes (capability-gated). | +| `PluginResult::modify(p, owned)` | Pass. Both payload and extension changes. | + +Audit / Concurrent / FireAndForget plugins should only use `allow()` and `deny()` — `modify_*` calls in those modes are dropped by the executor (the plugin lacks the authority). + +### 6.6 Multiple Hooks per Plugin + +A single plugin can implement `HookHandler` for several hook types. Each `impl HookHandler` block is independent — they can share `&self` state but don't have to. + +```rust +impl HookHandler for IdentityResolver { + async fn handle(&self, p: &ToolInvokePayload, e: &Extensions, c: &mut PluginContext) + -> PluginResult + { /* ... */ } +} + +impl HookHandler for IdentityResolver { + async fn handle(&self, p: &ToolInvokePayload, e: &Extensions, c: &mut PluginContext) + -> PluginResult + { /* ... */ } +} +``` + +Register each separately: + +```rust +manager.register_handler::( + Arc::clone(&plugin), config_for("tool_pre_invoke"))?; +manager.register_handler::( + plugin, config_for("tool_post_invoke"))?; +``` + +For the **CMF pattern** — one handler covers many CMF hook *names* (`cmf.tool_pre_invoke`, `cmf.llm_input`, `cmf.llm_output`, etc.) all carrying the same `MessagePayload` — define a single `CmfHook` marker and register it under multiple names: + +```rust +manager.register_handler_for_names::( + plugin, + config, + &[ + "cmf.tool_pre_invoke", + "cmf.tool_post_invoke", + "cmf.llm_input", + "cmf.llm_output", + ], +)?; +``` + +This is the case where `invoke_named::("cmf.tool_pre_invoke", ...)` matters — the type pins the payload to `MessagePayload`, but the runtime hook name selects which set of plugins to fire. + +### 6.7 Capability-Gated Extension Writes + +Extensions visible to a plugin are filtered by its declared `capabilities`. The framework uses copy-on-write tokens for writes — the plugin clones the extensions, gets a `WriteToken` for slots it has capabilities for, and returns the modified copy. + +```rust +use cpex_core::hooks::payload::Extensions; + +async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, +) -> PluginResult { + let mut owned = extensions.cow_copy(); + + // http_write_token is Some(...) iff the plugin declared `write_headers` + if let Some(ref token) = owned.http_write_token { + if let Some(http) = owned.http.as_mut() { + let h = http.write(token); + h.set_response_header("X-Tool-Name", &payload.message.role); + h.set_response_header("X-CPEX-Processed", "true"); + } + } + + PluginResult::modify_extensions(owned) +} +``` + +A plugin without the capability sees `owned.http_write_token == None` and silently can't write — no runtime panic, no security violation. The token *is* the type-system enforcement of the YAML capability. + +**Common capabilities** (see `cpex_core::extensions` for the full list): + +| Capability | Grants | +|---|---| +| `read_subject` | `SecurityExtension.subject` (read) | +| `read_labels` | `SecurityExtension.labels` (read) | +| `read_headers` | `HttpExtension.request_headers` (read) | +| `write_headers` | `HttpExtension.response_headers` (read + write token) | +| `read_classification` | `SecurityExtension.classification` (read) | +| `write_labels` | `SecurityExtension.labels` (read + monotonic write — append-only) | +| `read_data` / `write_data` | `SecurityExtension.data` | +| `read_objects` | `SecurityExtension.objects` | + +`MonotonicSet`-typed fields (like `labels`) only allow append, never removal — the executor enforces this on the post-handle merge. + +### 6.8 Choosing on_error + +`on_error` is set per-plugin in YAML or `PluginConfig`. Choose based on what failure means for the request: + +- **`fail`** — security/policy plugins. Can't enforce → halt the request. +- **`ignore`** — observability plugins (audit, metrics). Failure is annoying but non-fatal. +- **`disable`** — non-essential plugins with potential to fail repeatedly (e.g., a stale external dependency). The framework auto-disables the plugin after one failure to stop log spam. + +`Ignore` and `Disable` failures are recorded in `PipelineResult.errors` — they are not silent. + +## 7. Factories & Registration + +Two registration paths: + +| Path | When to use | +|---|---| +| `register_handler::` | You construct the plugin in Rust (compiled into the host). Direct, no factory needed. | +| `from_config(path, &factories)` | Plugin set is determined by YAML at runtime. Each `kind` in YAML maps to a registered `PluginFactory`. | + +You can mix them: register infrastructure plugins programmatically, then `load_config` to add YAML-configured ones. + +### 7.1 The PluginFactory Trait + +```rust +pub trait PluginFactory: Send + Sync { + fn create(&self, config: &PluginConfig) -> Result>; +} +``` + +A factory takes a `PluginConfig` (one entry from `plugins:` in YAML) and produces a `PluginInstance`. It's responsible for: + +1. Constructing the plugin (`Arc::new(MyPlugin { ... })`). +2. Building one `TypedHandlerAdapter` per hook the plugin handles. +3. Returning the bundle as a `PluginInstance`. + +### 7.2 PluginInstance + +```rust +pub struct PluginInstance { + pub plugin: Arc, + pub handlers: Vec<(&'static str, Arc)>, +} +``` + +`handlers` is one entry per hook name. For a plugin that handles two hooks (`tool_pre_invoke`, `tool_post_invoke`), the factory returns a `PluginInstance` with two entries. + +### 7.3 PluginFactoryRegistry + +```rust +pub struct PluginFactoryRegistry { /* private */ } + +impl PluginFactoryRegistry { + pub fn new() -> Self; + pub fn register(&mut self, kind: impl Into, factory: Box); + pub fn get(&self, kind: &str) -> Option<&dyn PluginFactory>; + pub fn has(&self, kind: &str) -> bool; + pub fn kinds(&self) -> Vec<&str>; +} +``` + +Populate before calling `PluginManager::from_config(path, &factories)`. The manager dispatches by `config.kind` — if the kind isn't registered, it returns `PluginError::Config { message: "unknown kind: ..." }`. + +## 8. Extensions + +Extensions are typed sidecar data carried alongside the payload. They are **always** a separate parameter — never inside the payload — because they need per-plugin capability filtering and independent modification. + +```rust +pub struct Extensions { + pub meta: Option>, + pub security: Option>, + pub http: Option>, + pub delegation: Option>, + pub agent: Option>, + pub request: Option>, + pub mcp: Option>, + pub completion: Option>, + pub provenance: Option>, + pub llm: Option>, + pub framework: Option>, + pub custom: HashMap, +} +``` + +| Extension | Purpose | +|---|---| +| `Meta` | Entity identification for route resolution (`entity_type`, `entity_name`, `tags`) | +| `Security` | Identity, labels, classification, data policies, authmethod, agent identity | +| `Http` | Request/response headers | +| `Delegation` | Token delegation chain (per-hop subject, audience, scope) | +| `Agent` | Agent execution context (session, conversation, turn) | +| `Request` | Environment, request ID, trace/span IDs, timestamp | +| `MCP` | MCP entity metadata (tool/resource/prompt server IDs) | +| `Completion` | LLM stats (stop reason, tokens, model, latency) | +| `Provenance` | Origin and message threading | +| `LLM` | Model identity (provider, capabilities) | +| `Framework` | Agentic framework context (framework name, node/graph IDs) | +| `Custom` | Free-form key-value | + +Each extension is held behind `Arc` so cloning the `Extensions` container is cheap — only the field mutated needs a deep clone. `OwnedExtensions` is the mutable form returned from `cow_copy()`. + +For capability-gated writes, see §6.7. + +## 9. CMF Payloads & Hooks + +**CMF (ContextForge Message Format)** is a typed multi-part message used by the agentic-pipeline hooks (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). The full spec is in [cmf-message-spec.md](./cmf-message-spec.md); the highlights: + +```rust +use cpex_core::cmf::{Message, MessagePayload, ContentPart, Role}; +use serde_json::json; + +let msg = MessagePayload { + message: Message { + schema_version: "1.0".into(), + role: Role::User, + content: vec![ + ContentPart::Text("Look up compensation".into()), + ContentPart::ToolCall(ToolCall { + tool_call_id: "tc_001".into(), + name: "get_compensation".into(), + arguments: json!({"employee_id": 42}), + ..Default::default() + }), + ], + channel: None, + }, +}; +``` + +`MessagePayload` already implements `PluginPayload` — no `impl_plugin_payload!` needed. + +**Built-in CMF hooks** (registered when you wire CMF into the manager): + +| Hook | Purpose | +|---|---| +| `cmf.tool_pre_invoke` | Before tool execution | +| `cmf.tool_post_invoke` | After tool execution | +| `cmf.llm_input` | Before LLM call | +| `cmf.llm_output` | After LLM response | +| `cmf.prompt_pre_fetch` / `cmf.prompt_post_fetch` | Prompt fetch lifecycle | +| `cmf.resource_pre_fetch` / `cmf.resource_post_fetch` | Resource fetch lifecycle | + +A single plugin registers a `CmfHook` marker against multiple names with `register_handler_for_names`, then dispatches via `invoke_named::(name, ...)`. See §6.6. + +## 10. YAML Configuration + +The full structure of the config file consumed by `load_config_file`: + +```yaml +plugin_settings: + routing_enabled: true # turn on route resolution (vs legacy conditions) + plugin_timeout: 30 # default timeout in seconds + +global: + policies: + all: # reserved — fires on every invocation + plugins: [identity-resolver] + pii: # custom group — fires when route has "pii" tag + plugins: [pii-guard] + +plugins: + - name: identity-resolver + kind: builtin/identity # must match a registered factory key + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: [read_subject] + config: # opaque to framework — passed to factory + strict_mode: true + + - name: pii-guard + kind: builtin/pii + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + capabilities: [read_labels, read_subject] + + - name: audit-logger + kind: builtin/audit + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + +routes: + - tool: get_compensation + meta: + tags: [pii, hr] # adds tags to MetaExtension for matching tools + plugins: + - audit-logger # route-specific override + + - tool: list_departments + plugins: + - audit-logger + + - tool: "*" # wildcard — catch-all + plugins: + - audit-logger +``` + +**Routes** are evaluated in order; first match wins. The wildcard `"*"` catches anything not matched by an earlier route. `meta.tags` augments the `MetaExtension.tags` for the matched tool, which can then trigger tag-based policy groups. + +**Policy groups** are named bundles of plugins. The `"all"` group is reserved and always fires. Other groups (e.g., `pii`) fire when a route's tags include the group name. + +Loading: + +```rust +let mut factories = PluginFactoryRegistry::new(); +factories.register("builtin/identity", Box::new(IdentityFactory)); +factories.register("builtin/pii", Box::new(PiiFactory)); +factories.register("builtin/audit", Box::new(AuditFactory)); + +let manager = PluginManager::from_config(Path::new("plugins.yaml"), &factories)?; +manager.initialize().await?; +``` + +## 11. Sample Plugin: Full Worked Example + +This walks through a complete native-Rust plugin from payload definition to invocation. Source for reference: [crates/cpex-core/examples/plugin_demo.rs](../../crates/cpex-core/examples/plugin_demo.rs). + +### 11.1 Define the Payload + +```rust +use cpex_core::impl_plugin_payload; + +#[derive(Debug, Clone)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} +impl_plugin_payload!(ToolInvokePayload); +``` + +### 11.2 Define the Hook Types + +```rust +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; + +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} +``` + +### 11.3 Implement the Plugin + +```rust +use std::sync::Arc; +use async_trait::async_trait; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::HookHandler; +use cpex_core::plugin::{Plugin, PluginConfig}; + +/// Plugin that requires a non-empty `user` field on every invocation. +struct IdentityResolver { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for IdentityResolver { + fn config(&self) -> &PluginConfig { &self.cfg } + + async fn initialize(&self) -> Result<(), Box> { + println!("[identity-resolver] initialized"); + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + println!("[identity-resolver] shutdown"); + Ok(()) + } +} +``` + +### 11.4 Implement the Hook Handlers + +```rust +impl HookHandler for IdentityResolver { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.user.is_empty() { + return PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )); + } + PluginResult::allow() + } +} + +impl HookHandler for IdentityResolver { + async fn handle( + &self, + _payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} +``` + +### 11.5 Build a Factory + +```rust +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::registry::AnyHookHandler; + +struct IdentityFactory; + +impl PluginFactory for IdentityFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(IdentityResolver { cfg: config.clone() }); + + let mut handlers: Vec<(&'static str, Arc)> = Vec::new(); + for hook in &config.hooks { + match hook.as_str() { + "tool_pre_invoke" => handlers.push(( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))), + )), + "tool_post_invoke" => handlers.push(( + "tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))), + )), + other => return Err(PluginError::Config { + message: format!("identity-resolver doesn't handle hook '{}'", other), + }.boxed()), + } + } + + Ok(PluginInstance { + plugin: plugin as Arc, + handlers, + }) + } +} +``` + +### 11.6 Register and Invoke (Programmatic) + +Use `register_handler::` for compile-time dispatch and `invoke::` for the typed call path. The compiler enforces that the payload you pass matches `H::Payload`. + +```rust +use cpex_core::manager::{PluginManager, ManagerConfig}; +use cpex_core::plugin::{PluginConfig, PluginMode, OnError}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let manager = PluginManager::new(ManagerConfig::default()); + + // Programmatic registration — skips the factory entirely. + let cfg = PluginConfig { + name: "identity-resolver".into(), + kind: "builtin/identity".into(), + hooks: vec!["tool_pre_invoke".into()], + mode: PluginMode::Sequential, + on_error: OnError::Fail, + ..Default::default() + }; + let plugin = Arc::new(IdentityResolver { cfg: cfg.clone() }); + manager.register_handler::(plugin, cfg)?; + + manager.initialize().await?; + + // Typed invoke — payload type must be ToolPreInvoke::Payload (= ToolInvokePayload). + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: r#"{"employee_id": 42}"#.into(), + }; + + let (result, _bg) = manager.invoke::( + payload, + Extensions::default(), + None, + ).await; + + if result.is_denied() { + let v = result.violation.unwrap(); + eprintln!("DENIED: {} [{}]", v.reason, v.code); + } else { + println!("ALLOWED"); + } + + // Soft errors (on_error: ignore/disable plugins) land here. + for record in &result.errors { + eprintln!( + "soft error from {}: {}", + record.plugin_name, record.message, + ); + } + + manager.shutdown().await; + Ok(()) +} +``` + +### 11.7 Threading Context Across Hooks + +For pre/post hook pairs, thread the returned `PluginContextTable` from the pre-hook into the post-hook so each plugin sees its own `local_state` from earlier: + +```rust +let (pre_result, _bg) = manager.invoke::( + payload.clone(), ext.clone(), None, +).await; + +// Tool runs here ... +let tool_output = run_tool(&payload).await?; + +// Post-hook: pass pre_result.context_table so plugins see their stashed local_state. +let (post_result, _bg) = manager.invoke::( + payload, ext, Some(pre_result.context_table), +).await; +``` + +The first invoke takes `None`; subsequent invokes within the same logical request thread `Some(prev.context_table)` through. + +### 11.8 Register and Invoke (Config-driven) + +For YAML-driven registration, register the factory and call `from_config`: + +```rust +use cpex_core::factory::PluginFactoryRegistry; +use std::path::Path; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut factories = PluginFactoryRegistry::new(); + factories.register("builtin/identity", Box::new(IdentityFactory)); + + let manager = PluginManager::from_config( + Path::new("plugins.yaml"), + &factories, + )?; + manager.initialize().await?; + + /* same invoke:: as above */ + + manager.shutdown().await; + Ok(()) +} +``` + +## 12. PluginContext & State + +Every `HookHandler::handle` call receives a `&mut PluginContext`. It carries two state stores: + +```rust +pub struct PluginContext { + pub plugin_id: PluginId, + pub local_state: HashMap, // per-plugin, persists across hooks + pub global_state: HashMap, // shared across plugins, scoped to one invoke chain + /* helpers */ +} +``` + +| Store | Scope | Use case | +|---|---|---| +| `local_state` | Plugin-private, persists across multiple hooks within one request (`tool_pre_invoke` → `tool_post_invoke`) | Stash per-request data the plugin will need on the corresponding post-hook (e.g., a timer started in pre, stopped in post) | +| `global_state` | Shared across plugins within one invoke chain | Pass data from one plugin to another (e.g., identity-resolver populates `user_id`, downstream plugins read it) | + +Threading `local_state` across hooks is the entire reason `PluginContextTable` exists — the embedder threads the returned context table from one invoke into the next, and the framework hydrates each plugin's `local_state` from the table. + +`global_state` is committed back to a canonical store after each plugin runs (in Sequential phase) so the next plugin sees the merged view. + +## 13. Error Handling + +The framework surfaces failures through three channels, each with distinct semantics: + +| Channel | Triggers | Where it shows up | +|---|---|---| +| `Result<_, Box>` from `register_*`, `load_config`, `initialize` | Lifecycle errors: parse error, factory error, initialization error | Caller's `Err(...)` | +| `PipelineResult.violation: Option` | A plugin called `PluginResult::deny(...)` | Set when `result.is_denied() == true`; `result.continue_processing == false` | +| `PipelineResult.errors: Vec` | Plugin returned `Err` with `on_error: ignore` or `on_error: disable`; plugin timeout in non-blocking phase; FFI-layer issues | Soft-error log; pipeline still completed | + +Note: invoke methods (`invoke`, `invoke_named`, `invoke_by_name`) do **not** return `Result`. They always return `(PipelineResult, BackgroundTasks)`. All in-pipeline failures land in the channels above. This is deliberate — once you've reached invoke, "the framework couldn't run anything" isn't a possible state; either no plugins matched (and you get an `allow` result) or the pipeline ran and produced a result. + +```rust +let (result, _bg) = mgr.invoke::(payload, ext, ct).await; + +if !result.continue_processing { + let v = result.violation.unwrap(); + eprintln!("denied [{}]: {}", v.code, v.reason); + return; // halt downstream work +} + +// Soft errors — pipeline ran, but some plugins failed non-fatally. +for record in &result.errors { + log::warn!( + "plugin {} failed: {} ({})", + record.plugin_name, record.message, + record.code.as_deref().unwrap_or("-"), + ); +} +``` + +## 14. Threading & Async + +- `PluginManager` is `Send + Sync`. Use `Arc` and call `invoke::(&self, ...)` from many tasks concurrently. The internal registry uses `ArcSwap` for lock-free reads; mutations (registration, config load) clone-and-swap. +- Plugins must be `Send + Sync` (enforced by the `Plugin` trait bound). All plugin state shared via `&self` must be safe for concurrent access. +- `HookHandler::handle` is `async fn`. Plugins that don't need to await compile to a ready future with no observable cost; plugins that need to await per-invocation just use `.await`. Prefer caching state in `Plugin::initialize` and reading from cache on the hot path — `.await` in `handle` adds latency to every request. Never call `block_on` inside `handle`; the manager already runs you on a tokio task and nested blocking will panic. +- The framework runs Concurrent-phase handlers in a `tokio::task::JoinSet` — true parallelism if your plugins are CPU-bound. +- When embedded via cpex-ffi, all managers in the process share **one** tokio runtime. Worker thread count is configurable; see [cpex-go-spec.md](./cpex-go-spec.md) §5.9 for the FFI-side knobs. Within pure Rust, you control the runtime yourself (`#[tokio::main]` or manual `Runtime::new()`). + +## 15. Testing Plugins + +Native Rust plugins are easy to unit-test — instantiate the plugin, build a `PluginContext`, call `handle` directly without going through the manager. + +```rust +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::context::PluginContext; + use cpex_core::hooks::payload::Extensions; + use cpex_core::plugin::PluginId; + + #[tokio::test] + async fn rejects_empty_user() { + let plugin = IdentityResolver { + cfg: PluginConfig { name: "test".into(), ..Default::default() }, + }; + let payload = ToolInvokePayload { + tool_name: "test".into(), + user: "".into(), + arguments: "{}".into(), + }; + let mut ctx = PluginContext::new(PluginId::from(1)); + let result = HookHandler::::handle( + &plugin, &payload, &Extensions::default(), &mut ctx).await; + assert!(result.violation.is_some()); + assert_eq!(result.violation.unwrap().code, "no_identity"); + } +} +``` + +For integration tests through the full pipeline, build a `PluginManager`, register the plugin, and `invoke::`. The `cpex-core` test suite has examples in `crates/cpex-core/src/manager.rs` (test module). + +## 16. Build & Test + +The repo Makefile is the canonical interface: + +| Target | What it does | +|---|---| +| `make rust-build` / `rust-build-release` | Build workspace (debug / release) | +| `make rust-test` | Full workspace tests | +| `make rust-test-ffi` | Only the cpex-ffi crate tests (faster iteration) | +| `make rust-lint-check` | Read-only `cargo fmt --check` + `cargo clippy -- -D warnings` | +| `make rust-lint` (or `rust-lint-fix`) | Mutating: `cargo fmt` + `clippy --fix` | +| `make examples-build` | Build all examples — catches stale public-API usage | +| `make examples-run` | Build + run each example end-to-end | +| `make ci` | Full CI gate (lint-check + test-all + examples-build) | + +Raw commands: + +```bash +# Build cpex-core +cargo build -p cpex-core + +# Run example +cargo run --example plugin_demo -p cpex-core +cargo run --example cmf_capabilities_demo -p cpex-core + +# Tests +cargo test --workspace +``` + +## 17. Dynamic Plugin Loading (Design Note) + +> **Status:** the C ABI path described below is shipped today (cpex-ffi, the Go integration). The Rust `cdylib` path is design-only — see §18 row "Native (`dlopen`) plugin loader." + +The framework is built so dynamic plugins (loaded at runtime from a `.so` / `.dylib` / `.dll`) work without changing the typed plugin-author API. The architecture deliberately separates two layers: + +``` +HookHandler ← native AFIT, monomorphized inside the plugin's binary + ↓ wrapped by TypedHandlerAdapter at registration time +AnyHookHandler ← object-safe; #[async_trait] boxes the future + ↓ vtable +Arc ← THIS is what crosses module boundaries +``` + +The typed `HookHandler` is non-object-safe (because of `impl Future` return-position) — you can't have `Box>` and you definitely can't put one across `dlopen`. That's intentional. The plugin compiles its own `TypedHandlerAdapter` and erases to `Arc` *inside its own binary* before handing anything to the host. The host only ever sees `dyn AnyHookHandler`, which has a stable vtable. + +### 17.1 Two transport strategies + +| Strategy | Status | What crosses the boundary | +|---|---|---| +| **C ABI via cpex-ffi** | Shipped (Go integration) | `extern "C"` functions, opaque manager handles, MessagePack-encoded payloads. Plugins never touch `HookHandler` directly — they implement whatever the FFI shim exposes. See [cpex-go-spec.md](./cpex-go-spec.md). | +| **Rust `cdylib` via `dlopen`** | Not implemented | A `cdylib` exports a registration entry point that returns `Arc` (or a vec of named handlers). Host loads via `libloading` and registers via `PluginManager::register_raw`. | + +### 17.2 Async stays end-to-end + +Both transports preserve full async behavior: + +``` +host: arc_handler.invoke(payload, ext, ctx).await + ↓ (vtable call across the module boundary) +plugin: TypedHandlerAdapter::invoke + ↓ (downcast to H::Payload + .await) +plugin: handle(...).await // plugin can await JWKS, RPC, anything +``` + +`#[async_trait]` boxes the typed future into `Pin>` at the `AnyHookHandler` boundary. That boxed future is what crosses the module line. The host awaits it on its own tokio runtime; the plugin's `.await` points are pause points inside that future. + +### 17.3 Constraints and gotchas + +Independent of which transport you pick: + +- **Shared runtime.** The plugin's future doesn't carry its own runtime — it gets driven by whichever tokio runtime the host is awaiting on. In the cpex-ffi path that's the process-shared runtime; in a Rust-cdylib path it'd be whatever the host has running. Plugins must not spawn or own a runtime themselves. +- **No nested `block_on`.** A dynamic plugin must never `block_on` inside `handle` — the future is already running on a tokio task and nested blocking will panic. Same rule as in-tree plugins, but easier to forget when the plugin lives in someone else's repo. +- **Panic isolation.** The host wraps every `AnyHookHandler::invoke` call in `catch_unwind`. cpex-ffi already does this at the C boundary; a Rust `cdylib` host would do the same at the registration shim. + +Specific to the Rust `cdylib` path: + +- **Rust ABI instability.** Plugin and host must be compiled with the same compiler version *and* same dependency versions. Different versions = UB. Mitigations: pin both, ship the host crate as a `=` version requirement, or use the `abi_stable` crate (gives a C-compatible vtable at the cost of an extra layer). +- **Allocator boundaries.** A `Box`/`Arc` allocated by the plugin must be dropped by the same allocator. The simplest path is for both sides to use the system allocator; otherwise the plugin must expose a free function the host calls on drop. +- **Symbol visibility.** The plugin's registration entry point must be `#[no_mangle] pub extern "C"` so `dlsym` can find it. Everything else can stay regular Rust. + +### 17.4 Why this works without changing the typed API + +The handler-collapse work in §6.2 (single async `HookHandler` trait) is orthogonal to dynamic loading. AFIT lives at the typed layer (inside the plugin's own binary); the module boundary lives at the type-erased layer. They don't collide. Plugin authors writing native, FFI, or hypothetical-cdylib plugins all write the same `async fn handle(...)` against the same `HookHandler` trait — only the registration shim changes between transports. + +## 18. Gaps and Unimplemented Features + +| Feature | Python Location | Status in Rust | +|---|---|---| +| `invoke_hook_for_plugin(name, hook, payload)` | `manager.py` | Not implemented — no single-plugin invoke | +| `HookPayloadPolicy` (field-level write control) | `hooks/policies.py` | Not implemented — capabilities are slot-level, not field-level | +| Programmatic capability rebinding per-invoke | `extensions/tiers.py` | Not implemented — capabilities are config-level only | +| `TenantPluginManager` (multi-tenant in one manager) | `manager.py` | Not implemented — one manager per tenant (shared runtime caps total threads when via FFI) | +| Observability provider injection | `manager.py` | Not implemented — observability via `tracing` crate | +| `reset()` (reinitialize without restart) | `manager.py` | Not implemented — shutdown and recreate | +| External plugin transports (gRPC/Unix/MCP) | `framework/external/` | Not yet implemented | +| Isolated (subprocess) plugins | `framework/isolated/` | Not yet implemented | +| PDP (AuthZen/OPA) integration | `framework/pdp/` | Not yet implemented | +| WASM plugin loader | `cpex-hosts::wasm` (planned) | Not yet implemented | +| Native (`dlopen`) plugin loader | `cpex-hosts::native` (planned) | Not yet implemented | +| `retry_delay_ms` in `PipelineResult` | `models.py` | Not implemented | + +The `cpex_core::plugin::Plugin` trait doc-comment mentions `cpex-hosts::{wasm,python,native}` host crates that would bridge to non-Rust plugin runtimes. None exist yet — this is a design intent placeholder, not shipped functionality. From c1659fbf89c6a4dac2863876be4e5a6040523c20 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 11 May 2026 09:54:09 -0600 Subject: [PATCH 06/11] feat: change Plugin handler to async for performance (#49) Co-authored-by: Teryl Taylor --- .../examples/cmf_capabilities_demo.rs | 6 +- crates/cpex-core/examples/plugin_demo.rs | 149 +++++++++++++++++- crates/cpex-core/examples/plugin_demo.yaml | 25 +++ crates/cpex-core/src/hooks/adapter.rs | 27 +++- crates/cpex-core/src/hooks/trait_def.rs | 70 ++++++-- crates/cpex-core/src/manager.rs | 123 ++++++++++++++- crates/cpex-ffi/src/lib.rs | 2 +- examples/go-demo/ffi/src/cmf_plugins.rs | 4 +- examples/go-demo/ffi/src/demo_plugins.rs | 6 +- 9 files changed, 375 insertions(+), 37 deletions(-) diff --git a/crates/cpex-core/examples/cmf_capabilities_demo.rs b/crates/cpex-core/examples/cmf_capabilities_demo.rs index 230c5a36..8843a30e 100644 --- a/crates/cpex-core/examples/cmf_capabilities_demo.rs +++ b/crates/cpex-core/examples/cmf_capabilities_demo.rs @@ -42,7 +42,7 @@ impl Plugin for IdentityChecker { } impl HookHandler for IdentityChecker { - fn handle( + async fn handle( &self, payload: &MessagePayload, extensions: &Extensions, @@ -136,7 +136,7 @@ impl Plugin for HeaderInjector { } impl HookHandler for HeaderInjector { - fn handle( + async fn handle( &self, _payload: &MessagePayload, extensions: &Extensions, @@ -201,7 +201,7 @@ impl Plugin for AuditLogger { } impl HookHandler for AuditLogger { - fn handle( + async fn handle( &self, payload: &MessagePayload, extensions: &Extensions, diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs index f0d28f6d..12cfd3f5 100644 --- a/crates/cpex-core/examples/plugin_demo.rs +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -76,7 +76,7 @@ impl Plugin for IdentityResolver { } impl HookHandler for IdentityResolver { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -98,7 +98,7 @@ impl HookHandler for IdentityResolver { } impl HookHandler for IdentityResolver { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -126,7 +126,7 @@ impl Plugin for PiiGuard { } impl HookHandler for PiiGuard { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -171,7 +171,7 @@ impl Plugin for AuditLogger { } impl HookHandler for AuditLogger { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -186,7 +186,7 @@ impl HookHandler for AuditLogger { } impl HookHandler for AuditLogger { - fn handle( + async fn handle( &self, payload: &ToolInvokePayload, _extensions: &Extensions, @@ -200,6 +200,89 @@ impl HookHandler for AuditLogger { } } +// --------------------------------------------------------------------------- +// Awaiting plugin example — RemoteAuthz +// --------------------------------------------------------------------------- +// +// `HookHandler` is async by design — `handle` is `async fn`. +// Plugins that don't need to `.await` anything still write +// `async fn handle` and return synchronously; this plugin shows the +// other direction, where the body genuinely awaits per-invocation +// work. The realistic version would call a remote authz service +// (gRPC, HTTP, OPA, Cedarling, etc.); here we simulate the network +// round-trip with a small `tokio::time::sleep` so the demo runs +// offline. +// +// Key things this shows: +// 1. Per-request latency state is *cached at init* — the handler +// consults the in-memory ACL and only "calls out" on a miss. +// Hot-path I/O is the most common source of latency regressions +// in plugins, so prefer initialize-time loading wherever you can. +// 2. Registration uses the exact same factory pattern as any other +// plugin — `TypedHandlerAdapter::` and the same +// `register_factory` call. There is no separate async path. +struct RemoteAuthz { + cfg: PluginConfig, + /// ACL "fetched" at init. Populated in Plugin::initialize. + allowed_users: tokio::sync::RwLock>, +} + +#[async_trait] +impl Plugin for RemoteAuthz { + fn config(&self) -> &PluginConfig { + &self.cfg + } + /// Pretend we're loading the ACL from a remote service. In a real + /// plugin this would be `client.fetch_acl().await`; we simulate + /// the round-trip with a small sleep so the demo runs offline. + async fn initialize(&self) -> Result<(), Box> { + tokio::time::sleep(std::time::Duration::from_millis(2)).await; + let mut acl = self.allowed_users.write().await; + acl.extend(["alice", "bob"].iter().map(|s| s.to_string())); + println!( + " [remote-authz] initialized — ACL cached ({} users)", + acl.len() + ); + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + println!(" [remote-authz] shutdown"); + Ok(()) + } +} + +impl HookHandler for RemoteAuthz { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Cache hit path — fast. + let acl = self.allowed_users.read().await; + if acl.contains(&payload.user) { + println!( + " [remote-authz] OK (cache hit): user '{}' allowed", + payload.user + ); + return PluginResult::allow(); + } + drop(acl); // release read lock before the fake remote call + // Cache miss path — simulate a remote authz check. In a real + // plugin this is where you'd `.await` a gRPC or HTTP call. + // The latency cost is real and shows up on the request path. + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + println!( + " [remote-authz] DENIED (cache miss + remote check): user '{}'", + payload.user + ); + PluginResult::deny(PluginViolation::new( + "remote_authz_denied", + format!("User '{}' not in remote ACL", payload.user), + )) + } +} + // --------------------------------------------------------------------------- // Step 3: Create plugin factories // --------------------------------------------------------------------------- @@ -264,6 +347,27 @@ impl PluginFactory for AuditLoggerFactory { } } +/// Factory for the async plugin. Note the factory body is identical +/// in shape to the sync factories above — `TypedHandlerAdapter` and +/// the `register_factory` path don't care that the underlying handler +/// is async. The framework hides the choice. +struct RemoteAuthzFactory; +impl PluginFactory for RemoteAuthzFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(RemoteAuthz { + cfg: config.clone(), + allowed_users: tokio::sync::RwLock::new(std::collections::HashSet::new()), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + // --------------------------------------------------------------------------- // Step 4: Build extensions with MetaExtension for routing // --------------------------------------------------------------------------- @@ -318,6 +422,7 @@ async fn main() { mgr.register_factory("builtin/identity", Box::new(IdentityFactory)); mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory)); mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); + mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory)); mgr.load_config(cpex_config).unwrap(); println!("\n--- Initializing plugins ---\n"); @@ -401,8 +506,38 @@ async fn main() { print_result("some_other_tool (wildcard)", &result); bg.wait_for_background_tasks().await; - // --- Scenario 5: No user identity --- - println!("=== Scenario 5: list_departments (no user identity) ===\n"); + // --- Scenario 5: Awaiting plugin — cache hit --- + // RemoteAuthz's `handle` is `async fn` and reads from a tokio + // RwLock. Its initialize() pre-loaded an ACL containing "alice" + // and "bob"; this call exercises the cache-hit fast path. + println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n"); + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "alice".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_tool_extensions("query_external_data", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("query_external_data (alice — in ACL)", &result); + bg.wait_for_background_tasks().await; + + // --- Scenario 6: Awaiting plugin — cache miss path with .await --- + // "charlie" is not in the cached ACL, so RemoteAuthz takes the + // cache-miss branch and `.await`s a simulated remote call before + // denying. + println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n"); + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "charlie".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_tool_extensions("query_external_data", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("query_external_data (charlie — not in ACL)", &result); + bg.wait_for_background_tasks().await; + + // --- Scenario 7: No user identity --- + println!("=== Scenario 7: list_departments (no user identity) ===\n"); let payload = ToolInvokePayload { tool_name: "list_departments".into(), user: "".into(), diff --git a/crates/cpex-core/examples/plugin_demo.yaml b/crates/cpex-core/examples/plugin_demo.yaml index 9e3dd610..07051e95 100644 --- a/crates/cpex-core/examples/plugin_demo.yaml +++ b/crates/cpex-core/examples/plugin_demo.yaml @@ -15,6 +15,10 @@ global: # "pii" group — activated when a route has the "pii" tag pii: plugins: [pii-guard] + # "external_authz" group — activated when a route has the + # "needs_remote_authz" tag. Fires the async RemoteAuthz plugin. + external_authz: + plugins: [remote-authz] plugins: - name: identity-resolver @@ -33,6 +37,17 @@ plugins: config: clearance_level: confidential + # Awaiting plugin — its `handle` body uses `.await` for a + # (simulated) remote authz call on cache miss. Wired in exactly + # the same way as plugins whose `handle` body has no `.await`; + # registration is identical either way. + - name: remote-authz + kind: builtin/remote_authz + hooks: [tool_pre_invoke] + mode: sequential + priority: 30 + on_error: fail + - name: audit-logger kind: builtin/audit hooks: [tool_pre_invoke, tool_post_invoke] @@ -53,6 +68,16 @@ routes: plugins: - audit-logger + # Tool that requires remote authz — triggers the async plugin + # on top of the standard "all" policy stack. The "external_authz" + # tag matches the policy group of the same name, which fires + # remote-authz. + - tool: query_external_data + meta: + tags: [external_authz] + plugins: + - audit-logger + # Wildcard — catch-all for unmatched tools - tool: "*" plugins: diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs index 7acc7b12..985108ca 100644 --- a/crates/cpex-core/src/hooks/adapter.rs +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -3,14 +3,20 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Teryl Taylor // -// TypedHandlerAdapter — bridges typed HookHandler to type-erased -// AnyHookHandler. +// TypedHandlerAdapter — bridges typed HookHandler to the +// type-erased AnyHookHandler. // // This is framework plumbing that plugin authors never see. When a // plugin is registered via `manager.register_handler::()`, the // manager creates a TypedHandlerAdapter internally. The adapter // translates between Box (what the executor passes) -// and the concrete payload type (what the handler expects). +// and the concrete payload type (what the handler expects), and awaits +// the typed handler's future before re-erasing the result. +// +// `HookHandler` is async-by-default (native AFIT). Plugins that +// don't await anything still write `async fn handle(...)`; the +// compiler emits a trivially-ready future that LLVM inlines, so the +// `.await` here is a no-op for sync-style plugins. use std::marker::PhantomData; use std::sync::Arc; @@ -33,6 +39,11 @@ use crate::registry::AnyHookHandler; /// Created automatically by `PluginManager::register_handler()`. Plugin /// authors never instantiate this directly. /// +/// `HookHandler` is async (native AFIT), so the adapter awaits the +/// returned future before re-erasing the result. Plugins that don't +/// `.await` anything compile to a ready future that LLVM inlines, so +/// they pay no observable cost over a plain function call. +/// /// # Type Parameters /// /// - `H` — the hook type (implements `HookTypeDef`). @@ -73,12 +84,18 @@ where P: Plugin + HookHandler + 'static, { /// Downcast the type-erased payload to the concrete type and call - /// the plugin's typed `handle()` method. + /// the plugin's typed `handle()` method, awaiting the returned + /// future. /// /// The framework retains ownership of the payload — the handler /// receives a borrow (`&H::Payload`) and clones only if it needs /// to modify. The result is erased back to `ErasedResultFields` /// for the executor. + /// + /// For plugins whose body contains no `.await`, the compiler emits + /// a trivially-ready future and LLVM inlines this `.await` to a + /// direct return — there is no observable runtime cost over a + /// plain function call. async fn invoke( &self, payload: &dyn PluginPayload, @@ -97,7 +114,7 @@ where ), })?; - let result = self.plugin.handle(typed_ref, extensions, ctx); + let result = self.plugin.handle(typed_ref, extensions, ctx).await; let plugin_result: PluginResult = result.into(); Ok(erase_result(plugin_result)) diff --git a/crates/cpex-core/src/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs index e07c7ab0..b75d2b8a 100644 --- a/crates/cpex-core/src/hooks/trait_def.rs +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -78,28 +78,70 @@ pub trait HookTypeDef: Send + Sync + 'static { /// Plugin authors implement this trait (alongside [`Plugin`]) to handle /// a specific hook. The type parameter `H` ties the handler to a /// `HookTypeDef`, ensuring the correct payload and result types at -/// compile time. +/// compile time. The framework creates a type-erased adapter internally +/// when you register — you never touch `AnyHookHandler` directly. /// -/// The framework creates a type-erased adapter internally when you -/// register — you never touch `AnyHookHandler` directly. +/// # Async by design +/// +/// `handle` is an `async fn`. Plugins that don't need to `.await` +/// anything still write `async fn handle(...)` and return synchronously +/// — the compiler emits a trivially-ready future and LLVM inlines it +/// at the adapter site, so there's no observable runtime cost over a +/// plain function. Plugins that *do* need to `.await` (fresh JWKS +/// fetch, RPC to an authz service, dynamic policy lookup) just use +/// `.await` inside the body. +/// +/// **Best practice:** even when async is available, prefer pre-loading +/// state in [`Plugin::initialize`] and reading from cache in `handle`. +/// Hot-path I/O is the most common source of latency regressions. +/// +/// # Native AFIT, not `#[async_trait]` +/// +/// The trait uses native `async fn` (return-position `impl Future`) +/// rather than `#[async_trait]`. This avoids a per-call heap +/// allocation: the returned future is monomorphized into the +/// [`TypedHandlerAdapter`] rather than boxed. The trait is therefore +/// **not object-safe** — you cannot have `Box>`. +/// We don't need that; type erasure happens one layer up at +/// [`AnyHookHandler`]. /// /// # Examples /// /// ```rust,ignore -/// impl HookHandler for MyPlugin { -/// fn handle( +/// // Synchronous plugin — no .await, no extra cost +/// impl HookHandler for AllowPlugin { +/// async fn handle( /// &self, -/// payload: MessagePayload, -/// extensions: &Extensions, -/// ctx: &PluginContext, +/// _payload: &MessagePayload, +/// _extensions: &Extensions, +/// _ctx: &mut PluginContext, /// ) -> PluginResult { /// PluginResult::allow() /// } /// } /// -/// // Registration — no AnyHookHandler needed: -/// manager.register_handler::(plugin, config)?; +/// // Async plugin — calls .await inside the body +/// impl HookHandler for AuthzPlugin { +/// async fn handle( +/// &self, +/// payload: &MyPayload, +/// _extensions: &Extensions, +/// _ctx: &mut PluginContext, +/// ) -> PluginResult { +/// match self.client.check(&payload.user).await { +/// Ok(true) => PluginResult::allow(), +/// _ => PluginResult::deny(/* ... */), +/// } +/// } +/// } +/// +/// // Registration is the same for both: +/// manager.register_handler::(plugin, config)?; /// ``` +/// +/// [`PluginManager::register_handler`]: crate::manager::PluginManager::register_handler +/// [`AnyHookHandler`]: crate::registry::AnyHookHandler +/// [`TypedHandlerAdapter`]: crate::hooks::adapter::TypedHandlerAdapter pub trait HookHandler: Plugin + Send + Sync { /// Handle the hook invocation. /// @@ -112,12 +154,18 @@ pub trait HookHandler: Plugin + Send + Sync { /// the modified copy in `PluginResult::modify_payload()`. This /// pushes the clone cost to the plugin that actually needs it — /// read-only plugins (validators, auditors) never pay for a copy. + /// + /// Returns a `Send`-able future so the executor can drive it from + /// any worker thread (including the concurrent-phase `JoinSet`). + /// `H::Result` is already `Send + Sync` per the `HookTypeDef` + /// bound, so the `Send` constraint comes for free for typical + /// handlers. fn handle( &self, payload: &H::Payload, extensions: &Extensions, ctx: &mut PluginContext, - ) -> H::Result; + ) -> impl std::future::Future + Send; } // --------------------------------------------------------------------------- diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 3ad5977a..16764d49 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -1280,7 +1280,7 @@ mod tests { } impl HookHandler for AllowPlugin { - fn handle( + async fn handle( &self, _payload: &TestPayload, _extensions: &Extensions, @@ -1309,7 +1309,7 @@ mod tests { } impl HookHandler for DenyPlugin { - fn handle( + async fn handle( &self, _payload: &TestPayload, _extensions: &Extensions, @@ -2068,7 +2068,7 @@ mod tests { } impl HookHandler for TransformPlugin { - fn handle( + async fn handle( &self, payload: &TestPayload, _extensions: &Extensions, @@ -3348,7 +3348,7 @@ routes: } } impl HookHandler for LifecyclePlugin { - fn handle( + async fn handle( &self, _payload: &TestPayload, _extensions: &Extensions, @@ -4089,7 +4089,7 @@ routes: } impl HookHandler for InitTrackingPlugin { - fn handle( + async fn handle( &self, _payload: &TestPayload, _extensions: &Extensions, @@ -4842,4 +4842,117 @@ routes: // With filter_extensions, security IS Some but with empty labels and no subject // So saw_security will be true, but the content is filtered } + + // ----------------------------------------------------------------------- + // Awaiting handler tests + // + // `HookHandler` is async by design. These tests cover handlers + // that genuinely `.await` inside the body — sleeps, yields, and + // co-registration with handlers whose body has no `.await` at all. + // ----------------------------------------------------------------------- + + /// Plugin that genuinely awaits inside its handler. Increments a + /// shared counter after the await resolves so the test can verify + /// the handler ran end-to-end and observed its async point. + struct AsyncCounterPlugin { + cfg: PluginConfig, + counter: Arc, + } + + #[async_trait] + impl Plugin for AsyncCounterPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + + impl HookHandler for AsyncCounterPlugin { + async fn handle( + &self, + _payload: &TestPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_micros(1)).await; + self.counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + PluginResult::allow() + } + } + + /// Verifies that a handler that genuinely `.await`s gets driven + /// to completion before its result is observed. + #[tokio::test] + async fn test_async_handler_registers_and_invokes() { + let mgr = PluginManager::default(); + let counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let cfg = make_config("async-counter", 10, PluginMode::Sequential); + let plugin = Arc::new(AsyncCounterPlugin { + cfg: cfg.clone(), + counter: counter.clone(), + }); + + // Same call path as sync plugins — no `register_async_handler`. + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + assert!(result.violation.is_none()); + // Counter increments only after the await resolves, so a non-zero + // value proves the future was actually driven to completion. + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 1, + "async handler should have run once", + ); + } + + /// A handler with no `.await` (AllowPlugin) and a handler that + /// genuinely awaits (AsyncCounterPlugin) co-register on the same + /// hook via the same `register_handler` call. Both run in priority + /// order. + #[tokio::test] + async fn test_mixed_sync_and_async_handlers_in_same_hook() { + let mgr = PluginManager::default(); + let counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + + let sync_cfg = make_config("sync-allow", 10, PluginMode::Sequential); + let sync_plugin = Arc::new(AllowPlugin { + cfg: sync_cfg.clone(), + }); + mgr.register_handler::(sync_plugin, sync_cfg) + .unwrap(); + + let async_cfg = make_config("async-counter", 20, PluginMode::Sequential); + let async_plugin = Arc::new(AsyncCounterPlugin { + cfg: async_cfg.clone(), + counter: counter.clone(), + }); + mgr.register_handler::(async_plugin, async_cfg) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let (result, _) = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), None) + .await; + + assert!(result.continue_processing); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 1, + "awaiting plugin should have run alongside the non-awaiting plugin", + ); + } } diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index f8bb615f..760f62d9 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -1032,7 +1032,7 @@ mod tests { } impl cpex_core::hooks::HookHandler for PanickingPlugin { - fn handle( + async fn handle( &self, _payload: &GenericPayload, _extensions: &Extensions, diff --git a/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs index f59d9e84..a033576f 100644 --- a/examples/go-demo/ffi/src/cmf_plugins.rs +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -68,7 +68,7 @@ impl Plugin for ToolPolicyPlugin { } impl HookHandler for ToolPolicyPlugin { - fn handle( + async fn handle( &self, payload: &MessagePayload, extensions: &Extensions, @@ -197,7 +197,7 @@ impl Plugin for HeaderInjectorPlugin { } impl HookHandler for HeaderInjectorPlugin { - fn handle( + async fn handle( &self, payload: &MessagePayload, extensions: &Extensions, diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs index 84351929..f27125c9 100644 --- a/examples/go-demo/ffi/src/demo_plugins.rs +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -61,7 +61,7 @@ impl Plugin for IdentityChecker { } impl HookHandler for IdentityChecker { - fn handle( + async fn handle( &self, payload: &GenericPayload, extensions: &Extensions, @@ -130,7 +130,7 @@ impl Plugin for PiiGuard { } impl HookHandler for PiiGuard { - fn handle( + async fn handle( &self, payload: &GenericPayload, extensions: &Extensions, @@ -212,7 +212,7 @@ impl Plugin for AuditLogger { } impl HookHandler for AuditLogger { - fn handle( + async fn handle( &self, payload: &GenericPayload, extensions: &Extensions, From 310d0db80d23b93affb0a91a59a4f5c577916226 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:31:59 -0600 Subject: [PATCH 07/11] fix: missing cmf-demo main.go file and gitignore fix that missed it (#52) Co-authored-by: Teryl Taylor --- .gitignore | 27 ++- examples/go-demo/.gitignore | 11 +- examples/go-demo/cmd/cmf-demo/main.go | 272 ++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 examples/go-demo/cmd/cmf-demo/main.go diff --git a/.gitignore b/.gitignore index beb86085..f6a8c150 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ token.txt cpex.sbom.xml docs/docs/test/ docs/resources/ -tmp *.tgz *.gz *.bz @@ -48,8 +47,10 @@ node_modules/ mcp.db-journal mcp.db-shm mcp.db-wal -certs/ -jwt/ +# Anchored: matches only ./certs/ at the repo root, not nested +# `certs/` directories under source. Bare `certs/` would silently +# hide any cert-management module / test fixture at any depth. +/certs/ FIXMEs *.old logs/ @@ -62,19 +63,22 @@ corpus/ tests/fuzz/fuzzers/results/ .venv mcp.db -public/ +# Anchored to repo root — bare `public/` would shadow any nested +# `public/` directory in source (common in web/frontend code). +/public/ ica_integrations_host.sbom.json .pyre dictionary.dic pdm.lock .pdm-python temp/ -public/ *history.md htmlcov test_commands.md cover.md -build/ +# Anchored: bare `build/` would shadow any nested build-output dir +# anywhere in the source tree. +/build/ .icaenv commands_output.txt commands_output.md @@ -94,7 +98,6 @@ scribeflow.log coverage_re bin/flagged flagged/ -certs/ # VENV .python37/ .python39/ @@ -111,16 +114,20 @@ __pycache__/ # C extensions *.so -# Distribution / packaging +# Distribution / packaging — Python build artifacts. `build/` and +# `lib/` are anchored (root-only) so they don't silently hide +# nested source dirs of the same name. Other patterns (`dist/`, +# `downloads/`, `eggs/`, …) stay bare — they're less likely to +# collide with source-tree directory names. .wily/ .Python -build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ -lib/ +# Anchored: bare `lib/` would shadow any nested `lib/` source dir. +/lib/ lib64/ parts/ sdist/ diff --git a/examples/go-demo/.gitignore b/examples/go-demo/.gitignore index 8123b755..6d4b7557 100644 --- a/examples/go-demo/.gitignore +++ b/examples/go-demo/.gitignore @@ -1,3 +1,8 @@ -# Built demo binaries -cpex-demo -cmf-demo +# Built demo binaries. Patterns are anchored (leading slash) so they +# match *files* at their build-output locations, not arbitrary path +# components — the previous unanchored `cmf-demo` rule was silently +# ignoring the `cmd/cmf-demo/` source directory and everything under +# it. +/cpex-demo +/cmf-demo +/cmd/cmf-demo/cmf-demo diff --git a/examples/go-demo/cmd/cmf-demo/main.go b/examples/go-demo/cmd/cmf-demo/main.go new file mode 100644 index 00000000..c550d33a --- /dev/null +++ b/examples/go-demo/cmd/cmf-demo/main.go @@ -0,0 +1,272 @@ +// Location: ./examples/go-demo/cmd/cmf-demo/main.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX CMF Demo — typed message processing with rich extensions. +// +// Demonstrates CMF (ContextForge Message Format) message processing +// through the CPEX plugin pipeline: +// +// 1. Build typed CMF messages (tool calls, tool results) +// 2. Attach security extensions (labels, subject), HTTP headers, +// and agent context +// 3. Invoke cmf.tool_pre_invoke — policy checks tool permissions +// against security labels and meta tags +// 4. Invoke cmf.tool_post_invoke — header injector adds response +// headers using capability-gated write access +// 5. Inspect modified extensions (injected headers) in results +// +// Build & run: +// +// cd examples/go-demo/ffi && cargo build --release +// cd examples/go-demo && go run ./cmd/cmf-demo + +package main + +/* +#cgo LDFLAGS: -L${SRCDIR}/../../../../target/release -lcpex_demo_ffi -lm -ldl -lpthread -framework CoreFoundation -framework Security +#include + +int cpex_demo_register_factories(void* mgr); +*/ +import "C" + +import ( + "fmt" + "os" + "unsafe" + + cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +) + +func main() { + fmt.Println("=== CPEX CMF Demo ===") + fmt.Println() + + // --- Setup --- + mgr, err := cpex.NewPluginManagerDefault() + if err != nil { + fatal("create manager: %v", err) + } + defer mgr.Shutdown() + + err = mgr.RegisterFactories(func(handle unsafe.Pointer) error { + if C.cpex_demo_register_factories(handle) != 0 { + return fmt.Errorf("factory registration failed") + } + return nil + }) + if err != nil { + fatal("register factories: %v", err) + } + + yaml, err := os.ReadFile("../../cmf_plugins.yaml") + if err != nil { + // Try current directory too + yaml, err = os.ReadFile("cmf_plugins.yaml") + if err != nil { + fatal("read config: %v", err) + } + } + + if err := mgr.LoadConfig(string(yaml)); err != nil { + fatal("load config: %v", err) + } + if err := mgr.Initialize(); err != nil { + fatal("initialize: %v", err) + } + + fmt.Printf("Plugins loaded: %d\n", mgr.PluginCount()) + fmt.Printf("Hooks: cmf.tool_pre_invoke=%v cmf.tool_post_invoke=%v\n\n", + mgr.HasHooksFor("cmf.tool_pre_invoke"), + mgr.HasHooksFor("cmf.tool_post_invoke"), + ) + + // ----------------------------------------------------------------------- + // Scenario 1: PII tool call WITHOUT security label — DENIED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 1: get_compensation tool call (no PII label) ===") + fmt.Println() + + msg := cpex.MessagePayload{ + Message: cpex.NewMessage("assistant", + cpex.NewTextPart("I'll look up the compensation data for you."), + cpex.NewToolCallPart(cpex.ToolCall{ + ToolCallID: "tc_001", + Name: "get_compensation", + Arguments: map[string]any{"employee_id": 42}, + Namespace: "hr", + }), + ), + } + + ext := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + Security: &cpex.SecurityExtension{ + Labels: []string{}, // no PII label — should be denied + Subject: &cpex.SubjectExtension{ + ID: "alice", + Roles: []string{"hr_analyst"}, + }, + }, + Http: &cpex.HttpExtension{ + RequestHeaders: map[string]string{ + "Authorization": "Bearer eyJ...", + "X-Request-ID": "req-001", + }, + }, + Agent: &cpex.AgentExtension{ + SessionID: "sess_abc123", + AgentID: "hr-assistant", + }, + } + + result, ct, bg, err := mgr.InvokeByName("cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, msg, ext, nil) + if err != nil { + fatal("invoke: %v", err) + } + printResult(result) + bg.Close() + ct.Close() + + // ----------------------------------------------------------------------- + // Scenario 2: PII tool call WITH security label — ALLOWED + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 2: get_compensation tool call (with PII label) ===") + fmt.Println() + + ext.Security.Labels = []string{"PII", "HR"} // now has PII label + + result, ct, bg, err = mgr.InvokeByName("cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, msg, ext, nil) + if err != nil { + fatal("invoke: %v", err) + } + printResult(result) + + // Check for modified extensions (header injector adds response headers) + // Check for modified extensions (header injector adds response headers) + if len(result.ModifiedExtensions) > 0 { + modExt, err := result.DeserializeExtensions() + if err != nil { + fmt.Printf(" (failed to deserialize modified extensions: %v)\n\n", err) + } else if modExt != nil && modExt.Http != nil && len(modExt.Http.ResponseHeaders) > 0 { + fmt.Println(" Modified response headers:") + for k, v := range modExt.Http.ResponseHeaders { + fmt.Printf(" %s: %s\n", k, v) + } + fmt.Println() + } + } + bg.Close() + + // ----------------------------------------------------------------------- + // Scenario 3: Post-invoke with tool result — header injection + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 3: tool result post-invoke (header injection) ===") + fmt.Println() + + resultMsg := cpex.MessagePayload{ + Message: cpex.NewMessage("tool", + cpex.NewToolResultPart(cpex.ToolResult{ + ToolCallID: "tc_001", + ToolName: "get_compensation", + Content: map[string]any{ + "employee_id": 42, + "salary": 125000, + "currency": "USD", + }, + IsError: false, + }), + ), + } + + postExt := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "get_compensation", + Tags: []string{"pii", "hr"}, + }, + Security: &cpex.SecurityExtension{ + Labels: []string{"PII", "HR"}, + }, + Http: &cpex.HttpExtension{ + RequestHeaders: map[string]string{ + "Authorization": "Bearer eyJ...", + "X-Request-ID": "req-001", + }, + }, + } + + result2, ct2, bg2, err := mgr.InvokeByName("cmf.tool_post_invoke", + cpex.PayloadCMFMessage, resultMsg, postExt, ct) + if err != nil { + fatal("post-invoke: %v", err) + } + printResult(result2) + + if len(result2.ModifiedExtensions) > 0 { + modExt, err := result2.DeserializeExtensions() + if err != nil { + fmt.Printf(" (failed to deserialize modified extensions: %v)\n\n", err) + } else if modExt != nil && modExt.Http != nil { + fmt.Println(" Modified response headers:") + for k, v := range modExt.Http.ResponseHeaders { + fmt.Printf(" %s: %s\n", k, v) + } + fmt.Println() + } + } + bg2.Close() + ct2.Close() + + // ----------------------------------------------------------------------- + // Scenario 4: Non-PII tool — allowed, no policy restriction + // ----------------------------------------------------------------------- + fmt.Println("=== Scenario 4: list_departments (non-PII, text message) ===") + fmt.Println() + + textMsg := cpex.MessagePayload{ + Message: cpex.NewMessage("user", + cpex.NewTextPart("Show me the list of departments"), + ), + } + + textExt := &cpex.Extensions{ + Meta: &cpex.MetaExtension{ + EntityType: "tool", + EntityName: "list_departments", + }, + } + + result, ct, bg, err = mgr.InvokeByName("cmf.tool_pre_invoke", + cpex.PayloadCMFMessage, textMsg, textExt, nil) + if err != nil { + fatal("invoke: %v", err) + } + printResult(result) + bg.Close() + ct.Close() + + fmt.Println("=== CMF Demo complete ===") +} + +func printResult(result *cpex.PipelineResult) { + if !result.IsDenied() { + fmt.Printf(" Result: ALLOWED\n\n") + } else { + v := result.Violation + fmt.Printf(" Result: DENIED — %s [%s]\n\n", v.Reason, v.Code) + } +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "ERROR: "+format+"\n", args...) + os.Exit(1) +} From 8804b357595931bb8ef1a70626b484cabebe9aa5 Mon Sep 17 00:00:00 2001 From: terylt <30874627+terylt@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:37:04 -0600 Subject: [PATCH 08/11] feat: initial APL Rust implementation (#60) * fix: initial revision APL. * feat: apl-cpex bridge crate + plugin-registry-driven hook dispatch * feat: add support for plugin calling in APL routes. * feat: add more APL plugin support, unified config * feat: added cedar direct PDP. * feat: add identity hook and extensions. * feat: added token delegation hooks and tests. * feat: added plugin for jwt token identity, oauth and biscuit delegation, cedarling PDP. Signed-off-by: Teryl Taylor * fix: updated identity and delegation to support keycloak. added delegate() function, and identity sections. * fix: added some sample plugins, added updates to support cedar. Signed-off-by: Teryl Taylor * feat: added session support, serialize and parallel and full effects capabilities. * feat: add ffi pre-built .a library Signed-off-by: Frederico Araujo * chore: add workflow_dispatch target Signed-off-by: Frederico Araujo * fix: critical and high issues from review. * feat: add APL FFI and go bindings Signed-off-by: Frederico Araujo * chore: add musl tools to musl runners Signed-off-by: Frederico Araujo * fix: potential double free after use bug. * chore: update Go module paths after repo rename to cpex * feat: map identity extension into cpex ffi Signed-off-by: Frederico Araujo * feat: add cpex_invoke_resolved abi Signed-off-by: Frederico Araujo * fix: has_hook_for handling Signed-off-by: Frederico Araujo * chore: update headers Signed-off-by: Frederico Araujo --------- Signed-off-by: Teryl Taylor Signed-off-by: Frederico Araujo Co-authored-by: Frederico Araujo --- .github/workflows/release-ffi.yaml | 190 + .gitignore | 19 +- CHANGELOG.md | 30 + Cargo.lock | 4968 +++++++++++++++-- Cargo.toml | 40 + crates/apl-audit-logger/Cargo.toml | 29 + crates/apl-audit-logger/src/config.rs | 33 + crates/apl-audit-logger/src/factory.rs | 55 + crates/apl-audit-logger/src/lib.rs | 40 + crates/apl-audit-logger/src/logger.rs | 254 + crates/apl-cedarling/Cargo.toml | 64 + crates/apl-cedarling/src/error.rs | 30 + crates/apl-cedarling/src/identity/mod.rs | 31 + crates/apl-cedarling/src/lib.rs | 48 + crates/apl-cedarling/src/pdp/mod.rs | 10 + crates/apl-cedarling/src/pdp/resolver.rs | 374 ++ crates/apl-cedarling/tests/pdp_basic.rs | 166 + crates/apl-cmf/Cargo.toml | 24 + crates/apl-cmf/src/agent.rs | 68 + crates/apl-cmf/src/capability_namespaces.rs | 312 ++ crates/apl-cmf/src/completion.rs | 76 + crates/apl-cmf/src/constants.rs | 113 + crates/apl-cmf/src/custom.rs | 42 + crates/apl-cmf/src/delegation.rs | 88 + crates/apl-cmf/src/extensions_bridge.rs | 94 + crates/apl-cmf/src/framework.rs | 55 + crates/apl-cmf/src/http.rs | 45 + crates/apl-cmf/src/lib.rs | 137 + crates/apl-cmf/src/llm.rs | 44 + crates/apl-cmf/src/mcp.rs | 92 + crates/apl-cmf/src/meta.rs | 57 + crates/apl-cmf/src/payload.rs | 150 + crates/apl-cmf/src/provenance.rs | 38 + crates/apl-cmf/src/request.rs | 54 + crates/apl-cmf/src/security.rs | 525 ++ crates/apl-cmf/tests/end_to_end.rs | 275 + crates/apl-core/Cargo.toml | 32 + crates/apl-core/src/attributes.rs | 215 + crates/apl-core/src/evaluator.rs | 2563 +++++++++ crates/apl-core/src/lib.rs | 45 + crates/apl-core/src/parser.rs | 3929 +++++++++++++ crates/apl-core/src/pipeline.rs | 134 + crates/apl-core/src/plugin_decl.rs | 290 + crates/apl-core/src/route.rs | 739 +++ crates/apl-core/src/rules.rs | 840 +++ crates/apl-core/src/step.rs | 506 ++ crates/apl-core/tests/yaml_end_to_end.rs | 266 + crates/apl-cpex/Cargo.toml | 43 + crates/apl-cpex/src/cmf_invoker.rs | 410 ++ crates/apl-cpex/src/delegation_invoker.rs | 269 + crates/apl-cpex/src/dispatch_plan.rs | 461 ++ crates/apl-cpex/src/lib.rs | 52 + crates/apl-cpex/src/parallel_safety.rs | 343 ++ crates/apl-cpex/src/pdp_router.rs | 207 + crates/apl-cpex/src/register.rs | 185 + crates/apl-cpex/src/route_handler.rs | 569 ++ crates/apl-cpex/src/session_resolver.rs | 432 ++ crates/apl-cpex/src/session_store.rs | 157 + crates/apl-cpex/src/visitor.rs | 680 +++ crates/apl-cpex/tests/capability_gating.rs | 446 ++ crates/apl-cpex/tests/cmf_invoker_dispatch.rs | 699 +++ crates/apl-cpex/tests/config_override.rs | 519 ++ crates/apl-cpex/tests/delegate_step_e2e.rs | 913 +++ crates/apl-cpex/tests/end_to_end_route.rs | 551 ++ crates/apl-cpex/tests/visitor_e2e.rs | 705 +++ crates/apl-delegator-biscuit/Cargo.toml | 67 + crates/apl-delegator-biscuit/src/config.rs | 161 + crates/apl-delegator-biscuit/src/delegator.rs | 279 + crates/apl-delegator-biscuit/src/lib.rs | 33 + .../tests/biscuit_e2e.rs | 316 ++ crates/apl-delegator-oauth/Cargo.toml | 69 + crates/apl-delegator-oauth/src/config.rs | 158 + crates/apl-delegator-oauth/src/delegator.rs | 474 ++ crates/apl-delegator-oauth/src/factory.rs | 59 + crates/apl-delegator-oauth/src/lib.rs | 29 + crates/apl-delegator-oauth/tests/oauth_e2e.rs | 382 ++ crates/apl-identity-jwt/Cargo.toml | 92 + crates/apl-identity-jwt/src/claim_map.rs | 401 ++ crates/apl-identity-jwt/src/config.rs | 511 ++ crates/apl-identity-jwt/src/factory.rs | 60 + crates/apl-identity-jwt/src/lib.rs | 61 + crates/apl-identity-jwt/src/resolver.rs | 834 +++ crates/apl-identity-jwt/src/trusted_issuer.rs | 198 + crates/apl-identity-jwt/tests/jwks_url_e2e.rs | 750 +++ crates/apl-identity-jwt/tests/jwt_e2e.rs | 298 + crates/apl-pdp-cedar-direct/Cargo.toml | 63 + .../apl-pdp-cedar-direct/src/cedar_attrs.rs | 61 + crates/apl-pdp-cedar-direct/src/decision.rs | 127 + crates/apl-pdp-cedar-direct/src/entities.rs | 253 + crates/apl-pdp-cedar-direct/src/error.rs | 67 + crates/apl-pdp-cedar-direct/src/factory.rs | 54 + crates/apl-pdp-cedar-direct/src/lib.rs | 114 + crates/apl-pdp-cedar-direct/src/request.rs | 216 + crates/apl-pdp-cedar-direct/src/resolver.rs | 301 + crates/apl-pdp-cedar-direct/src/template.rs | 281 + .../tests/basic_allow_deny.rs | 220 + .../tests/visitor_pdp_config.rs | 166 + crates/apl-pii-scanner/Cargo.toml | 28 + crates/apl-pii-scanner/src/config.rs | 85 + crates/apl-pii-scanner/src/factory.rs | 70 + crates/apl-pii-scanner/src/lib.rs | 30 + crates/apl-pii-scanner/src/scanner.rs | 322 ++ crates/cpex-core/Cargo.toml | 9 + crates/cpex-core/src/cmf/constants.rs | 31 + crates/cpex-core/src/cmf/message.rs | 14 + crates/cpex-core/src/config.rs | 709 +++ crates/cpex-core/src/delegation/hook.rs | 86 + crates/cpex-core/src/delegation/mod.rs | 21 + crates/cpex-core/src/delegation/payload.rs | 694 +++ crates/cpex-core/src/executor.rs | 279 +- .../cpex-core/src/extensions/authorization.rs | 81 + crates/cpex-core/src/extensions/container.rs | 43 + crates/cpex-core/src/extensions/delegation.rs | 75 +- crates/cpex-core/src/extensions/filter.rs | 324 +- crates/cpex-core/src/extensions/mod.rs | 13 +- .../src/extensions/raw_credentials.rs | 342 ++ crates/cpex-core/src/extensions/security.rs | 263 +- crates/cpex-core/src/extensions/tiers.rs | 72 +- crates/cpex-core/src/hooks/metadata.rs | 369 ++ crates/cpex-core/src/hooks/mod.rs | 2 + crates/cpex-core/src/identity/hook.rs | 99 + crates/cpex-core/src/identity/mod.rs | 25 + crates/cpex-core/src/identity/payload.rs | 460 ++ crates/cpex-core/src/identity/route_config.rs | 202 + crates/cpex-core/src/lib.rs | 7 + crates/cpex-core/src/manager.rs | 653 ++- crates/cpex-core/src/registry.rs | 18 + crates/cpex-core/src/visitor.rs | 134 + crates/cpex-core/tests/delegation_e2e.rs | 722 +++ crates/cpex-core/tests/identity_e2e.rs | 744 +++ crates/cpex-core/tests/identity_route_e2e.rs | 867 +++ crates/cpex-ffi/Cargo.toml | 19 + crates/cpex-ffi/RELEASE.md | 231 + crates/cpex-ffi/src/apl.rs | 101 + crates/cpex-ffi/src/lib.rs | 489 +- crates/cpex-orchestration/Cargo.toml | 34 + crates/cpex-orchestration/src/lib.rs | 449 ++ examples/go-demo/ffi/src/cmf_plugins.rs | 2 +- examples/go-demo/ffi/src/demo_plugins.rs | 2 +- examples/go-demo/ffi/src/lib.rs | 8 +- examples/go-demo/go.mod | 6 +- examples/go-demo/main.go | 2 +- go/cpex/README.md | 2 +- go/cpex/abi.go | 50 + go/cpex/apl.go | 61 + go/cpex/apl_test.go | 73 + go/cpex/constants.go | 4 + go/cpex/go.mod | 2 +- go/cpex/identity.go | 83 + go/cpex/manager.go | 154 +- go/cpex/manager_test.go | 110 + scripts/download-ffi-artifact.sh | 169 + scripts/release/build-artifact.sh | 120 + scripts/release/sign-artifact.sh | 61 + 154 files changed, 42426 insertions(+), 724 deletions(-) create mode 100644 .github/workflows/release-ffi.yaml create mode 100644 crates/apl-audit-logger/Cargo.toml create mode 100644 crates/apl-audit-logger/src/config.rs create mode 100644 crates/apl-audit-logger/src/factory.rs create mode 100644 crates/apl-audit-logger/src/lib.rs create mode 100644 crates/apl-audit-logger/src/logger.rs create mode 100644 crates/apl-cedarling/Cargo.toml create mode 100644 crates/apl-cedarling/src/error.rs create mode 100644 crates/apl-cedarling/src/identity/mod.rs create mode 100644 crates/apl-cedarling/src/lib.rs create mode 100644 crates/apl-cedarling/src/pdp/mod.rs create mode 100644 crates/apl-cedarling/src/pdp/resolver.rs create mode 100644 crates/apl-cedarling/tests/pdp_basic.rs create mode 100644 crates/apl-cmf/Cargo.toml create mode 100644 crates/apl-cmf/src/agent.rs create mode 100644 crates/apl-cmf/src/capability_namespaces.rs create mode 100644 crates/apl-cmf/src/completion.rs create mode 100644 crates/apl-cmf/src/constants.rs create mode 100644 crates/apl-cmf/src/custom.rs create mode 100644 crates/apl-cmf/src/delegation.rs create mode 100644 crates/apl-cmf/src/extensions_bridge.rs create mode 100644 crates/apl-cmf/src/framework.rs create mode 100644 crates/apl-cmf/src/http.rs create mode 100644 crates/apl-cmf/src/lib.rs create mode 100644 crates/apl-cmf/src/llm.rs create mode 100644 crates/apl-cmf/src/mcp.rs create mode 100644 crates/apl-cmf/src/meta.rs create mode 100644 crates/apl-cmf/src/payload.rs create mode 100644 crates/apl-cmf/src/provenance.rs create mode 100644 crates/apl-cmf/src/request.rs create mode 100644 crates/apl-cmf/src/security.rs create mode 100644 crates/apl-cmf/tests/end_to_end.rs create mode 100644 crates/apl-core/Cargo.toml create mode 100644 crates/apl-core/src/attributes.rs create mode 100644 crates/apl-core/src/evaluator.rs create mode 100644 crates/apl-core/src/lib.rs create mode 100644 crates/apl-core/src/parser.rs create mode 100644 crates/apl-core/src/pipeline.rs create mode 100644 crates/apl-core/src/plugin_decl.rs create mode 100644 crates/apl-core/src/route.rs create mode 100644 crates/apl-core/src/rules.rs create mode 100644 crates/apl-core/src/step.rs create mode 100644 crates/apl-core/tests/yaml_end_to_end.rs create mode 100644 crates/apl-cpex/Cargo.toml create mode 100644 crates/apl-cpex/src/cmf_invoker.rs create mode 100644 crates/apl-cpex/src/delegation_invoker.rs create mode 100644 crates/apl-cpex/src/dispatch_plan.rs create mode 100644 crates/apl-cpex/src/lib.rs create mode 100644 crates/apl-cpex/src/parallel_safety.rs create mode 100644 crates/apl-cpex/src/pdp_router.rs create mode 100644 crates/apl-cpex/src/register.rs create mode 100644 crates/apl-cpex/src/route_handler.rs create mode 100644 crates/apl-cpex/src/session_resolver.rs create mode 100644 crates/apl-cpex/src/session_store.rs create mode 100644 crates/apl-cpex/src/visitor.rs create mode 100644 crates/apl-cpex/tests/capability_gating.rs create mode 100644 crates/apl-cpex/tests/cmf_invoker_dispatch.rs create mode 100644 crates/apl-cpex/tests/config_override.rs create mode 100644 crates/apl-cpex/tests/delegate_step_e2e.rs create mode 100644 crates/apl-cpex/tests/end_to_end_route.rs create mode 100644 crates/apl-cpex/tests/visitor_e2e.rs create mode 100644 crates/apl-delegator-biscuit/Cargo.toml create mode 100644 crates/apl-delegator-biscuit/src/config.rs create mode 100644 crates/apl-delegator-biscuit/src/delegator.rs create mode 100644 crates/apl-delegator-biscuit/src/lib.rs create mode 100644 crates/apl-delegator-biscuit/tests/biscuit_e2e.rs create mode 100644 crates/apl-delegator-oauth/Cargo.toml create mode 100644 crates/apl-delegator-oauth/src/config.rs create mode 100644 crates/apl-delegator-oauth/src/delegator.rs create mode 100644 crates/apl-delegator-oauth/src/factory.rs create mode 100644 crates/apl-delegator-oauth/src/lib.rs create mode 100644 crates/apl-delegator-oauth/tests/oauth_e2e.rs create mode 100644 crates/apl-identity-jwt/Cargo.toml create mode 100644 crates/apl-identity-jwt/src/claim_map.rs create mode 100644 crates/apl-identity-jwt/src/config.rs create mode 100644 crates/apl-identity-jwt/src/factory.rs create mode 100644 crates/apl-identity-jwt/src/lib.rs create mode 100644 crates/apl-identity-jwt/src/resolver.rs create mode 100644 crates/apl-identity-jwt/src/trusted_issuer.rs create mode 100644 crates/apl-identity-jwt/tests/jwks_url_e2e.rs create mode 100644 crates/apl-identity-jwt/tests/jwt_e2e.rs create mode 100644 crates/apl-pdp-cedar-direct/Cargo.toml create mode 100644 crates/apl-pdp-cedar-direct/src/cedar_attrs.rs create mode 100644 crates/apl-pdp-cedar-direct/src/decision.rs create mode 100644 crates/apl-pdp-cedar-direct/src/entities.rs create mode 100644 crates/apl-pdp-cedar-direct/src/error.rs create mode 100644 crates/apl-pdp-cedar-direct/src/factory.rs create mode 100644 crates/apl-pdp-cedar-direct/src/lib.rs create mode 100644 crates/apl-pdp-cedar-direct/src/request.rs create mode 100644 crates/apl-pdp-cedar-direct/src/resolver.rs create mode 100644 crates/apl-pdp-cedar-direct/src/template.rs create mode 100644 crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs create mode 100644 crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs create mode 100644 crates/apl-pii-scanner/Cargo.toml create mode 100644 crates/apl-pii-scanner/src/config.rs create mode 100644 crates/apl-pii-scanner/src/factory.rs create mode 100644 crates/apl-pii-scanner/src/lib.rs create mode 100644 crates/apl-pii-scanner/src/scanner.rs create mode 100644 crates/cpex-core/src/delegation/hook.rs create mode 100644 crates/cpex-core/src/delegation/mod.rs create mode 100644 crates/cpex-core/src/delegation/payload.rs create mode 100644 crates/cpex-core/src/extensions/authorization.rs create mode 100644 crates/cpex-core/src/extensions/raw_credentials.rs create mode 100644 crates/cpex-core/src/hooks/metadata.rs create mode 100644 crates/cpex-core/src/identity/hook.rs create mode 100644 crates/cpex-core/src/identity/mod.rs create mode 100644 crates/cpex-core/src/identity/payload.rs create mode 100644 crates/cpex-core/src/identity/route_config.rs create mode 100644 crates/cpex-core/src/visitor.rs create mode 100644 crates/cpex-core/tests/delegation_e2e.rs create mode 100644 crates/cpex-core/tests/identity_e2e.rs create mode 100644 crates/cpex-core/tests/identity_route_e2e.rs create mode 100644 crates/cpex-ffi/RELEASE.md create mode 100644 crates/cpex-ffi/src/apl.rs create mode 100644 crates/cpex-orchestration/Cargo.toml create mode 100644 crates/cpex-orchestration/src/lib.rs create mode 100644 go/cpex/abi.go create mode 100644 go/cpex/apl.go create mode 100644 go/cpex/apl_test.go create mode 100644 go/cpex/identity.go create mode 100755 scripts/download-ffi-artifact.sh create mode 100755 scripts/release/build-artifact.sh create mode 100755 scripts/release/sign-artifact.sh diff --git a/.github/workflows/release-ffi.yaml b/.github/workflows/release-ffi.yaml new file mode 100644 index 00000000..9506d426 --- /dev/null +++ b/.github/workflows/release-ffi.yaml @@ -0,0 +1,190 @@ +# =============================================================== +# Release FFI - Build, sign, and publish libcpex_ffi.a artifacts +# =============================================================== +# +# Triggered by semver-strict tag pushes. Matrix-builds the FFI +# static library for the supported target tuples, packages each into +# a tarball with VERSION / FFI_ABI / LICENSE metadata, signs every +# tarball + the aggregate SHA256SUMS with cosign keyless (Sigstore), +# and attaches everything to the GitHub Release for the tag. +# +# See crates/cpex-ffi/RELEASE.md for the artifact schema and the +# consumer-side verify-and-unpack recipe. + +name: Release FFI + +on: + push: + tags: + # Semver-strict. Two patterns so vMAJOR.MINOR.PATCH (release) + # and vMAJOR.MINOR.PATCH- (rc / beta / ffi.test) + # both fire, while loose `v*` matches (vendor-bump, v1, v-foo) + # and the legacy non-prefixed tags (0.1.0, plugins.dev1) do not. + # Dry-run tags like v0.0.0-ffi.test.1 deliberately hit the + # prerelease branch. + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' + workflow_dispatch: + +# id-token: write is what unlocks Sigstore keyless signing (Fulcio +# reads the GHA OIDC token to issue the short-lived signing cert). +# contents: write is needed to create / upload to the GitHub Release. +permissions: + contents: write + id-token: write + +# Prevent concurrent runs on the same tag from racing the release +# creation. Tag pushes are one-shot, so this is belt-and-suspenders. +concurrency: + group: release-ffi-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false # one tuple failing should not cancel the others + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + runner: ubuntu-22.04-arm + - target: x86_64-unknown-linux-musl + runner: ubuntu-latest + - target: aarch64-unknown-linux-musl + runner: ubuntu-22.04-arm + - target: aarch64-apple-darwin + runner: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + # dtolnay/rust-toolchain is the de-facto rustup action. + # `stable` picks the latest stable; pin if we need a floor. + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + # Share cache across tags (the key includes the target + + # Cargo.lock hash by default). Significant speedup on + # subsequent releases of the same target. + key: ${{ matrix.target }} + + - name: Install musl toolchain + # ring (via jsonwebtoken/rustls/quinn) compiles C + asm through + # cc-rs, which for a *-unknown-linux-musl target shells out to + # -linux-musl-gcc. The runners ship only the glibc gcc, so + # we install musl-gcc here. The matrix runs each musl target on + # its native-arch runner, so musl-gcc targets the host arch and + # the CC_/LINKER env vars below redirect cc-rs and the linker to + # it. Scoped to the musl legs; gnu/darwin use their default cc. + if: contains(matrix.target, 'musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools musl-dev + + - name: Build artifact + env: + TARGET: ${{ matrix.target }} + # VERSION drops the leading "refs/tags/" so the tarball + # name matches the tag verbatim. + VERSION: ${{ github.ref_name }} + DIST_DIR: dist + # Point cc-rs and the linker at musl-gcc for the musl targets. + # No-ops for gnu/darwin (those triples don't match these keys). + CC_x86_64_unknown_linux_musl: musl-gcc + CC_aarch64_unknown_linux_musl: musl-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + run: bash scripts/release/build-artifact.sh + + - name: Upload tarball + sha256 + uses: actions/upload-artifact@v4 + with: + # Unique per-target name so the download step in + # sign-and-release can merge them all into one dist/. + name: cpex-ffi-${{ github.ref_name }}-${{ matrix.target }} + path: dist/cpex-ffi-* + if-no-files-found: error + retention-days: 7 + + sign-and-release: + name: Sign and publish release + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all matrix artifacts + uses: actions/download-artifact@v4 + with: + path: dist + # merge-multiple flattens the per-target subdirs into one + # dist/ so the sign script and gh release upload see a + # flat layout. + merge-multiple: true + + - name: Generate aggregate SHA256SUMS + env: + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + cd dist + # Concat all individual .sha256 files into one signed + # integrity manifest. The per-tarball .sha256 files stay + # as convenience companions, but the SHA256SUMS file is + # what auditors care about. + : > "cpex-ffi-${VERSION}-SHA256SUMS" + for f in cpex-ffi-*.tar.gz; do + if command -v sha256sum >/dev/null; then + sha256sum "$f" >> "cpex-ffi-${VERSION}-SHA256SUMS" + else + shasum -a 256 "$f" >> "cpex-ffi-${VERSION}-SHA256SUMS" + fi + done + echo "--- SHA256SUMS ---" + cat "cpex-ffi-${VERSION}-SHA256SUMS" + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign artifacts + env: + DIST_DIR: dist + run: bash scripts/release/sign-artifact.sh + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + # Auto-detect prerelease by suffix so dry-run tags + # (v0.0.0-ffi.test.1) land as prereleases and don't surface + # as "latest" on the repo's Releases page. + PRERELEASE_FLAG="" + if [[ "$TAG" == *-* ]]; then + PRERELEASE_FLAG="--prerelease" + fi + + # Idempotent: if the release exists, upload --clobber the + # new files; if it doesn't, create it with the tarballs + + # SHA256SUMS + sigs in one shot. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG exists; uploading artifacts with --clobber" + gh release upload "$TAG" dist/cpex-ffi-* --clobber + else + echo "Creating release $TAG" + gh release create "$TAG" \ + --title "$TAG" \ + --notes "Automated FFI artifact release. See crates/cpex-ffi/RELEASE.md for the schema and verify-and-consume recipe." \ + $PRERELEASE_FLAG \ + dist/cpex-ffi-* + fi diff --git a/.gitignore b/.gitignore index f6a8c150..4d957277 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ FIXMEs logs/ *.log .sketchpad +.localtarget* # Fuzzing artifacts and reports reports/ @@ -201,13 +202,29 @@ celerybeat-schedule # Environments .env +.env.* +*.env +env.bak/ +env.back +env.bak .venv env/ venv/ ENV/ -env.bak/ venv.bak/ +# Loose credential / token files — defensive net against accidental +# `git add` of dev-captured real tokens. The `.env` patterns above +# already cover the canonical case. +bearertoken* +*.token +*.tokens +*credentials*.json +*credentials*.yaml +*credentials*.yml +apikey* +*.pem + # Spyder project settings .spyderproject .spyproject diff --git a/CHANGELOG.md b/CHANGELOG.md index c0185621..cee356e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,36 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- APL (Attribute Policy Language) governance is now bundled into + `libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers + the standard APL plugin/PDP factories (`validator/pii-scan`, + `audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and + installs the APL config visitor on a manager. Call it after + `cpex_manager_new_default` and before `cpex_load_config`. Go hosts use + `PluginManager.EnableAPL()`. The optional `cedarling` cargo feature adds + the Cedarling-backed identity + PDP seams (off by default; the released + `.a` stays lean). +- Publish `libcpex_ffi.a` as signed GitHub Release artifacts on + every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`, + `linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign + keyless signatures + SHA256 checksums; see + `crates/cpex-ffi/RELEASE.md` for the schema and the verify-and- + consume recipe. +- FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor + exposes `FFI_ABI_VERSION`. The Go binding checks this in `init()` + and panics on mismatch. Other language bindings must replicate the + check. + +### Changed + +- FFI `FFI_ABI_VERSION` bumped `1 → 2`: added the `cpex_apl_install` + extern C function and changed `cpex_load_config` to run registered + config visitors (it now calls `load_config_yaml` internally so `apl:` + blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped + in lockstep. + ## [0.1.0] - 2026-05-05 ### Added diff --git a/Cargo.lock b/Cargo.lock index e8149dbc..36aebe0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,829 +3,4681 @@ version = 4 [[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "anyhow" -version = "1.0.102" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "arc-swap" -version = "1.9.1" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "rustversion", + "cfg-if", + "once_cell", + "version_check", + "zerocopy", ] [[package]] -name = "async-trait" -version = "0.1.89" +name = "aho-corasick" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ - "proc-macro2", - "quote", - "syn", + "memchr", ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "bitflags" -version = "2.11.0" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] [[package]] -name = "bumpalo" -version = "3.20.2" +name = "anyhow" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +name = "apl-audit-logger" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "cpex-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +name = "apl-cedarling" +version = "0.1.0" +dependencies = [ + "apl-core", + "async-trait", + "cedar-policy", + "cedarling", + "cpex-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "cpex-core" +name = "apl-cmf" +version = "0.1.0" +dependencies = [ + "apl-core", + "async-trait", + "cpex-core", + "serde_json", + "tokio", +] + +[[package]] +name = "apl-core" version = "0.1.0" dependencies = [ - "arc-swap", "async-trait", + "cpex-orchestration", "futures", - "hashbrown 0.15.5", + "regex", "serde", "serde_json", "serde_yaml", - "thiserror", + "thiserror 2.0.18", "tokio", - "tokio-util", - "tracing", - "uuid", - "wildmatch", ] [[package]] -name = "cpex-demo-ffi" +name = "apl-cpex" version = "0.1.0" dependencies = [ + "apl-cmf", + "apl-core", "async-trait", + "chrono", "cpex-core", - "cpex-ffi", + "serde", "serde_json", + "serde_yaml", + "sha2 0.10.9", + "tokio", "tracing", ] [[package]] -name = "cpex-ffi" +name = "apl-delegator-biscuit" version = "0.1.0" dependencies = [ + "apl-core", "async-trait", + "biscuit-auth", + "chrono", "cpex-core", - "rmp-serde", + "hex", "serde", - "serde_bytes", "serde_json", + "serde_yaml", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] -name = "cpex-sdk" +name = "apl-delegator-oauth" version = "0.1.0" dependencies = [ + "apl-core", "async-trait", + "chrono", "cpex-core", + "mockito", + "reqwest 0.12.28", "serde", "serde_json", + "serde_urlencoded", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", + "zeroize", ] [[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +name = "apl-identity-jwt" +version = "0.1.0" +dependencies = [ + "apl-core", + "async-trait", + "base64 0.22.1", + "chrono", + "cpex-core", + "futures", + "jsonwebtoken 9.3.1", + "mockito", + "rand 0.8.6", + "reqwest 0.12.28", + "rsa", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +name = "apl-pdp-cedar-direct" +version = "0.1.0" dependencies = [ - "libc", - "windows-sys", + "apl-cmf", + "apl-core", + "apl-cpex", + "async-trait", + "cedar-policy", + "cpex-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", ] [[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +name = "apl-pii-scanner" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "futures" -version = "0.3.32" +name = "ar_archive_writer" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "object", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "arc-swap" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ - "futures-core", - "futures-sink", + "rustversion", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "arraydeque" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] -name = "futures-executor" -version = "0.3.32" +name = "arrayvec" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "term", ] [[package]] -name = "futures-io" -version = "0.3.32" +name = "assert-json-diff" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "futures-task" -version = "0.3.32" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "futures-util" -version = "0.3.32" +name = "aws-lc-rs" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "aws-lc-sys", + "zeroize", ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "aws-lc-sys" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", + "cc", + "cmake", + "dunce", + "fs_extra", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "base16ct" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] -name = "hashbrown" -version = "0.17.0" +name = "base64" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] -name = "heck" -version = "0.5.0" +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] -name = "id-arena" -version = "2.3.0" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "indexmap" -version = "2.14.0" +name = "base64ct" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "biscuit-auth" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5884fc86b3e21f5649ef4326e17ef729b3096e6502deaf13db7b7fb05bb992b" dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", + "base64 0.13.1", + "biscuit-parser", + "biscuit-quote", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "getrandom 0.2.17", + "hex", + "nom", + "p256", + "pkcs8 0.9.0", + "prost", + "prost-types", + "rand 0.8.6", + "rand_core 0.6.4", + "regex", + "serde_json", + "sha2 0.9.9", + "thiserror 1.0.69", + "time", + "zeroize", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "biscuit-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d7cafdbc8c30e1f0fb87df7161bec77f6f00da652cc33f102b0f95bd1cbc0fa" +dependencies = [ + "hex", + "nom", + "proc-macro2", + "quote", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "biscuit-quote" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d2332c742a07a846f1fb2760e58a0ee60f2bc30987046fcea816b40630335a" +dependencies = [ + "biscuit-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cedar-policy" +version = "4.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674ca6ef6e44a1e29e6c07f6b2ab7e6913938d6049204d48e7849703d02443ce" +dependencies = [ + "cedar-policy-core", + "cedar-policy-formatter", + "itertools 0.14.0", + "linked-hash-map", + "miette", + "ref-cast", + "semver", + "serde", + "serde_json", + "serde_with", + "smol_str", + "thiserror 2.0.18", +] + +[[package]] +name = "cedar-policy-core" +version = "4.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df781c108240c3b3778c586c6a1b234355f1a2f9d35e08630b63b2590c59dbb" +dependencies = [ + "chrono", + "educe", + "either", + "itertools 0.14.0", + "lalrpop", + "lalrpop-util", + "linked-hash-map", + "linked_hash_set", + "miette", + "nonempty", + "ref-cast", + "regex", + "rustc-literal-escaper", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 2.0.18", + "unicode-security", +] + +[[package]] +name = "cedar-policy-formatter" +version = "4.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e293607563253e249d19cf4d36ac05821955170a63cf6d65deb4db60138b192e" +dependencies = [ + "cedar-policy-core", + "itertools 0.14.0", + "logos", + "miette", + "pretty", + "regex", + "smol_str", +] + +[[package]] +name = "cedarling" +version = "2.1.0" +source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" +dependencies = [ + "ahash", + "async-trait", + "base64 0.22.1", + "cedar-policy", + "cedar-policy-core", + "chrono", + "config", + "derive_more", + "flate2", + "futures", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.2", + "gloo-timers", + "hdrhistogram", + "http_utils", + "jsonwebtoken 10.4.0", + "rand 0.10.1", + "reqwest 0.13.3", + "semver", + "serde", + "serde_json", + "serde_yaml_ng", + "smol_str", + "sparkv", + "strum", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-util", + "typed-builder", + "url", + "uuid7", + "vfs", + "wasm-bindgen-futures", + "web-sys", + "zip", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "config" +version = "0.15.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" +dependencies = [ + "async-trait", + "convert_case 0.6.0", + "json5", + "pathdiff", + "ron", + "rust-ini", + "serde-untagged", + "serde_core", + "serde_json", + "toml", + "winnow", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpex-core" +version = "0.1.0" +dependencies = [ + "arc-swap", + "async-trait", + "chrono", + "cpex-orchestration", + "futures", + "hashbrown 0.15.5", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "wildmatch", + "zeroize", +] + +[[package]] +name = "cpex-demo-ffi" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-ffi", + "serde_json", + "tracing", +] + +[[package]] +name = "cpex-ffi" +version = "0.1.0" +dependencies = [ + "apl-audit-logger", + "apl-cedarling", + "apl-cpex", + "apl-delegator-oauth", + "apl-identity-jwt", + "apl-pdp-cedar-direct", + "apl-pii-scanner", + "async-trait", + "cpex-core", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "cpex-orchestration" +version = "0.1.0" +dependencies = [ + "futures", + "tokio", +] + +[[package]] +name = "cpex-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "serde", + "serde_json", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid 0.9.6", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fstr" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b8f793a77bb6d48059953a3e9820fd860d19a9bed8164ed3572eb1981ec8aa" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "base64 0.21.7", + "byteorder", + "crossbeam-channel", + "flate2", + "nom", + "num-traits", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http_utils" +version = "0.1.0" +source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" +dependencies = [ + "reqwest 0.13.3", + "serde", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +dependencies = [ + "serde", +] + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rust2" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e9ceaec84b54518262de7cf06b8b43e83c808349960f1610b21b0bfc9640f20" +dependencies = [ + "sha2 0.11.0", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "serde", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec", + "typed-arena", + "unicode-width 0.2.2", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71adf41db68aa0daaefc69bb30bcd68ded9b9abaad5d1fbb6304c4fb390e083e" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b670f45da57fb8542ebdbb6105a925fe571b67f9e7ed9f47a06a84e72b4e7cc" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d0a014229361011dc8e69c8a1ec6c2e8d0f2af7c91e3ea3f5b2170298461e68" +dependencies = [ + "bytes", + "prost", +] + +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "ron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc-literal-escaper" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be87abb9e40db7466e0681dc8ecd9dcfd40360cb10b4c8fe24a7c4c3669b198" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sparkv" +version = "0.1.1" +source = "git+https://github.com/JanssenProject/jans?tag=v2.1.0#3a089405993a1832135092857258c774cbbbb215" +dependencies = [ + "chrono", + "thiserror 2.0.18", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "der 0.6.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] -name = "js-sys" -version = "0.3.95" +name = "unicode-security" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" dependencies = [ - "once_cell", - "wasm-bindgen", + "unicode-normalization", + "unicode-script", ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "unicode-segmentation" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] -name = "libc" -version = "0.2.184" +name = "unicode-width" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] -name = "lock_api" -version = "0.4.14" +name = "unicode-width" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "log" -version = "0.4.29" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "memchr" -version = "2.8.0" +name = "unsafe-libyaml" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] -name = "mio" -version = "1.2.0" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "num-traits" -version = "0.2.19" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "autocfg", + "form_urlencoded", + "idna", + "percent-encoding", + "serde", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "parking_lot" -version = "0.12.5" +name = "uuid" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "lock_api", - "parking_lot_core", + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "uuid7" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "f14c93e6dd46ded457afc647964ac685427f9f001815d07ba30398cb79d9c9ce" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "fstr", + "rand_core 0.10.1", + "rand_core 0.6.4", + "serde", + "uuid", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "prettyplease" -version = "0.2.37" +name = "vfs" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "9e723b9e1c02a3cf9f9d0de6a4ddb8cdc1df859078902fe0ae0589d615711ae6" dependencies = [ - "proc-macro2", - "syn", + "filetime", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "walkdir" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "unicode-ident", + "same-file", + "winapi-util", ] [[package]] -name = "quote" -version = "1.0.45" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "proc-macro2", + "try-lock", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "bitflags", + "wit-bindgen", ] [[package]] -name = "rmp" -version = "0.8.15" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "num-traits", + "wit-bindgen", ] [[package]] -name = "rmp-serde" -version = "1.3.1" +name = "wasm-bindgen" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ - "rmp", - "serde", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "wasm-bindgen-futures" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "ryu" -version = "1.0.23" +name = "wasm-bindgen-macro" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "wasm-bindgen-macro-support" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] [[package]] -name = "semver" -version = "1.0.28" +name = "wasm-bindgen-shared" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] [[package]] -name = "serde" -version = "1.0.228" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "serde_core", - "serde_derive", + "leb128fmt", + "wasmparser", ] [[package]] -name = "serde_bytes" -version = "0.11.19" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "serde", - "serde_core", + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "serde_derive", + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "web-sys" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "webpki-root-certs" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", + "rustls-pki-types", ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "webpki-roots" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ - "errno", - "libc", + "rustls-pki-types", ] [[package]] -name = "slab" -version = "0.4.12" +name = "wildmatch" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" [[package]] -name = "smallvec" -version = "1.15.1" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] -name = "socket2" -version = "0.6.3" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "libc", - "windows-sys", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "syn" -version = "2.0.117" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.117", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-link", ] [[package]] -name = "tokio" -version = "1.51.1" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", + "windows-link", ] [[package]] -name = "tokio-macros" -version = "2.7.0" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-targets 0.52.6", ] [[package]] -name = "tokio-util" -version = "0.7.18" +name = "windows-sys" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", + "windows-targets 0.53.5", ] [[package]] -name = "tracing" -version = "0.1.44" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", + "windows-link", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "windows-targets" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "once_cell", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "windows_aarch64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] -name = "unsafe-libyaml" -version = "0.2.11" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "uuid" -version = "1.23.0" +name = "windows_aarch64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", -] +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "windows_i686_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "wasm-bindgen" -version = "0.2.118" +name = "windows_i686_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] -name = "wasm-bindgen-macro" -version = "0.2.118" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.118" +name = "windows_i686_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] -name = "wasm-bindgen-shared" -version = "0.2.118" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" -dependencies = [ - "unicode-ident", -] +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "windows_x86_64_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "wasmparser" -version = "0.244.0" +name = "windows_x86_64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] -name = "wildmatch" -version = "2.6.1" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows_x86_64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "winnow" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ - "windows-link", + "memchr", ] [[package]] @@ -856,9 +4708,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -874,7 +4726,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -887,7 +4739,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -906,7 +4758,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -916,8 +4768,208 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yaml-rust2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "8.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e499faf5c6b97a0d086f4a8733de6d47aee2252b8127962439d8d4311a73f72" +dependencies = [ + "bzip2", + "crc32fast", + "deflate64", + "flate2", + "indexmap 2.14.0", + "lzma-rust2", + "memchr", + "ppmd-rust", + "time", + "typed-path", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 62f40dac..da736d6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,46 @@ resolver = "2" members = [ "crates/cpex-core", + "crates/cpex-orchestration", "crates/cpex-sdk", "crates/cpex-ffi", + "crates/apl-core", + "crates/apl-cmf", + "crates/apl-cpex", + "crates/apl-pdp-cedar-direct", + "crates/apl-cedarling", + "crates/apl-identity-jwt", + "crates/apl-delegator-oauth", + "crates/apl-delegator-biscuit", + "crates/apl-pii-scanner", + "crates/apl-audit-logger", + "examples/go-demo/ffi", +] + +# `default-members` controls what `cargo build` / `cargo test` (with no +# `-p` or `--workspace` flag) picks up. Cedarling integration crates +# pull ~200 transitive deps (jsonwebtoken, reqwest, sparkv, datalogic-rs, +# flate2, etc.) and slow default builds significantly — excluding them +# from default-members keeps everyday iteration fast. +# +# To exercise Cedarling crates: +# cargo build --workspace # all members +# cargo build -p apl-cedarling # just this one +# cargo test --workspace # full sweep (CI) +default-members = [ + "crates/cpex-core", + "crates/cpex-orchestration", + "crates/cpex-sdk", + "crates/cpex-ffi", + "crates/apl-core", + "crates/apl-cmf", + "crates/apl-cpex", + "crates/apl-pdp-cedar-direct", + "crates/apl-identity-jwt", + "crates/apl-delegator-oauth", + "crates/apl-delegator-biscuit", + "crates/apl-pii-scanner", + "crates/apl-audit-logger", "examples/go-demo/ffi", ] @@ -37,3 +75,5 @@ arc-swap = "1.7" wildmatch = "2" rmp-serde = "1" serde_bytes = "0.11" +chrono = { version = "0.4", features = ["serde"] } +regex = "1" diff --git a/crates/apl-audit-logger/Cargo.toml b/crates/apl-audit-logger/Cargo.toml new file mode 100644 index 00000000..ad729e06 --- /dev/null +++ b/crates/apl-audit-logger/Cargo.toml @@ -0,0 +1,29 @@ +# Location: ./crates/apl-audit-logger/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-audit-logger — CMF plugin that emits a structured audit +# record for every dispatched request. Subject, client, action, +# delegation outcome, and capability-filtered context fields land +# in a single JSON line per call. Always allows; never blocks. + +[package] +name = "apl-audit-logger" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +cpex-core = { path = "../cpex-core" } + +async-trait = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-audit-logger/src/config.rs b/crates/apl-audit-logger/src/config.rs new file mode 100644 index 00000000..168750b1 --- /dev/null +++ b/crates/apl-audit-logger/src/config.rs @@ -0,0 +1,33 @@ +// Location: ./crates/apl-audit-logger/src/config.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuditLoggerConfig { + /// Where audit records go. Stderr is the default — convenient + /// for the demo (`docker compose logs -f`) and for k8s sidecar + /// log forwarding. Tracing routes through whatever subscriber + /// the host installed. + #[serde(default)] + pub destination: AuditDestination, + + /// Optional sink name — surfaces in every record so a single + /// audit collector can distinguish multiple deployments. Free- + /// form string. + #[serde(default)] + pub source: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditDestination { + /// Write one JSON line per call to stderr. + #[default] + Stderr, + /// Emit via `tracing::info!` at target `apl.audit`. Routed by + /// the host's subscriber to wherever traces normally go. + Tracing, +} diff --git a/crates/apl-audit-logger/src/factory.rs b/crates/apl-audit-logger/src/factory.rs new file mode 100644 index 00000000..05eb90fd --- /dev/null +++ b/crates/apl-audit-logger/src/factory.rs @@ -0,0 +1,55 @@ +// Location: ./crates/apl-audit-logger/src/factory.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use std::sync::Arc; + +use cpex_core::{ + cmf::CmfHook, + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + plugin::PluginConfig, +}; + +use crate::logger::AuditLogger; + +/// `kind:` string operators write in CPEX YAML to declare an audit +/// logger instance. +pub const KIND: &str = "audit/logger"; + +pub struct AuditLoggerFactory; + +impl PluginFactory for AuditLoggerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let logger = Arc::new(AuditLogger::new(config.clone())?); + + if config.hooks.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-audit-logger): `hooks:` must list at \ + least one CMF hook to audit (e.g. cmf.tool_pre_invoke)", + config.name + ), + })); + } + + let handlers: Vec<_> = config + .hooks + .iter() + .map(|h| -> (&'static str, _) { + let leaked: &'static str = Box::leak(h.clone().into_boxed_str()); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&logger)), + ); + (leaked, adapter) + }) + .collect(); + + Ok(PluginInstance { + plugin: logger, + handlers, + }) + } +} diff --git a/crates/apl-audit-logger/src/lib.rs b/crates/apl-audit-logger/src/lib.rs new file mode 100644 index 00000000..5671c372 --- /dev/null +++ b/crates/apl-audit-logger/src/lib.rs @@ -0,0 +1,40 @@ +// Location: ./crates/apl-audit-logger/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-audit-logger — CMF plugin that emits one structured JSON +// audit record per dispatched request. The record captures: +// +// * timestamp + correlation id +// * subject (id, roles, teams) and client (client_id, name) +// * entity (type + name) and tool args summary +// * delegation outcomes (which audiences got tokens, which +// scopes were granted) +// +// Mode: always allow — the plugin is observation-only. Operators +// who want to halt on audit failure would compose this with a +// downstream policy step. +// +// Output: +// +// * `destination: stderr` (default) — one JSON line per call, +// handy for the demo's `docker compose logs -f` flow. +// * `destination: tracing` — emit as a structured `tracing::info!` +// so it lands in whatever the host's subscriber routes to. +// +// Capabilities the plugin declares (operator wires them in YAML +// under `capabilities:`): +// +// * `read_subject` — for sub / roles / teams / claims +// * `read_client` — for client_id / client_name +// * `read_meta` — for entity_type / entity_name +// * `read_delegated_tokens` — to surface what got minted + +pub mod config; +pub mod factory; +pub mod logger; + +pub use config::{AuditDestination, AuditLoggerConfig}; +pub use factory::{AuditLoggerFactory, KIND}; +pub use logger::AuditLogger; diff --git a/crates/apl-audit-logger/src/logger.rs b/crates/apl-audit-logger/src/logger.rs new file mode 100644 index 00000000..7f6404d7 --- /dev/null +++ b/crates/apl-audit-logger/src/logger.rs @@ -0,0 +1,254 @@ +// Location: ./crates/apl-audit-logger/src/logger.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Map, Value}; + +use cpex_core::cmf::{CmfHook, ContentPart, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::config::{AuditDestination, AuditLoggerConfig}; + +/// Observation-only CMF plugin. Builds a structured audit record +/// from the request's MessagePayload + Extensions, emits to the +/// configured destination, returns `Allow`. Never blocks. +#[derive(Debug)] +pub struct AuditLogger { + cfg: PluginConfig, + typed: AuditLoggerConfig, +} + +impl AuditLogger { + pub fn new(cfg: PluginConfig) -> Result> { + let typed: AuditLoggerConfig = match cfg.config.as_ref() { + Some(raw) => serde_json::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-audit-logger) config parse failed: {e}", + cfg.name + ), + }) + })?, + None => AuditLoggerConfig::default(), + }; + Ok(Self { cfg, typed }) + } + + fn build_record(&self, payload: &MessagePayload, ext: &Extensions) -> Value { + let mut record = Map::new(); + record.insert( + "ts".into(), + json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), + ); + record.insert("plugin".into(), json!(self.cfg.name)); + if let Some(src) = &self.typed.source { + record.insert("source".into(), json!(src)); + } + + // Subject — capability-filtered. Empty Subject means the + // plugin lacks `read_subject` cap (won't happen if the + // operator configured it correctly). + if let Some(sec) = ext.security.as_ref() { + if let Some(s) = &sec.subject { + record.insert( + "subject".into(), + json!({ + "id": s.id, + "roles": s.roles.iter().collect::>(), + "teams": s.teams.iter().collect::>(), + }), + ); + } + if let Some(c) = &sec.client { + record.insert( + "client".into(), + json!({ + "client_id": c.client_id, + "client_name": c.client_name, + }), + ); + } + } + + // Entity — the route's tool/prompt/resource coords. + if let Some(meta) = ext.meta.as_ref() { + record.insert( + "entity".into(), + json!({ + "type": meta.entity_type, + "name": meta.entity_name, + }), + ); + } + + // Tool / prompt args summary — the first structured + // content part's args, if any. Mirrors what the gateway + // would actually forward (so audit reflects post-redact + // state if a PII scanner ran ahead of us). + for part in &payload.message.content { + match part { + ContentPart::ToolCall { content } => { + record.insert( + "tool_call".into(), + json!({ + "name": content.name, + "tool_call_id": content.tool_call_id, + "args": content.arguments, + }), + ); + break; + } + ContentPart::PromptRequest { content } => { + record.insert( + "prompt_request".into(), + json!({ + "name": content.name, + "args": content.arguments, + }), + ); + break; + } + _ => {} + } + } + + // Delegation outcomes — which audiences got tokens, with + // what (effective, possibly narrowed) scopes. The whole + // point of including this: it makes the audit trail show + // "we exchanged for workday-api with scope=read_compensation", + // which is the proof that delegation enforcement happened. + if let Some(raw) = ext.raw_credentials.as_ref() { + if !raw.delegated_tokens.is_empty() { + let tokens: Vec = raw + .delegated_tokens + .iter() + .map(|(_key, tok)| { + json!({ + "audience": tok.audience, + "scopes": tok.scopes, + "outbound_header": tok.outbound_header, + "expires_at": tok.expires_at.to_rfc3339_opts( + chrono::SecondsFormat::Secs, true, + ), + }) + }) + .collect(); + record.insert("delegated_tokens".into(), json!(tokens)); + } + } + + Value::Object(record) + } + + fn emit(&self, record: &Value) { + match self.typed.destination { + AuditDestination::Stderr => { + // One JSON line — easy to grep / forward / jq through. + eprintln!("{}", record); + } + AuditDestination::Tracing => { + tracing::info!(target: "apl.audit", record = %record, "audit"); + } + } + } +} + +#[async_trait] +impl Plugin for AuditLogger { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AuditLogger { + async fn handle( + &self, + payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let record = self.build_record(payload, ext); + self.emit(&record); + PluginResult::allow() + } +} + +// Silence import-unused warning if Arc isn't used elsewhere. +#[allow(dead_code)] +fn _force_link_arc(_: Arc<()>) {} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::{Message, Role, ToolCall}; + use cpex_core::extensions::{MetaExtension, SecurityExtension, SubjectExtension}; + use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + use std::collections::HashMap; + use std::sync::Arc; + + fn cfg() -> PluginConfig { + PluginConfig { + name: "audit".into(), + kind: "test".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 50, + on_error: OnError::Fail, + config: Some(serde_json::json!({ "destination": "stderr" })), + ..Default::default() + } + } + + #[tokio::test] + async fn build_record_includes_subject_entity_toolcall() { + let plugin = AuditLogger::new(cfg()).unwrap(); + let payload = MessagePayload { + message: Message::with_content( + Role::User, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "1".into(), + name: "get_compensation".into(), + arguments: HashMap::from([( + "employee_id".to_string(), + serde_json::json!("EMP-001234"), + )]), + namespace: None, + }, + }], + ), + }; + let mut sec = SecurityExtension::default(); + sec.subject = Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }); + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".into()); + meta.entity_name = Some("get_compensation".into()); + let ext = Extensions { + security: Some(Arc::new(sec)), + meta: Some(Arc::new(meta)), + ..Default::default() + }; + + let record = plugin.build_record(&payload, &ext); + assert_eq!(record["subject"]["id"], "alice@corp.com"); + assert_eq!(record["entity"]["name"], "get_compensation"); + assert_eq!(record["tool_call"]["name"], "get_compensation"); + assert_eq!(record["tool_call"]["args"]["employee_id"], "EMP-001234"); + // Always-allow contract: handler returns continue_processing. + let mut ctx = PluginContext::default(); + let r = plugin.handle(&payload, &ext, &mut ctx).await; + assert!(r.continue_processing); + assert!(r.violation.is_none()); + } +} diff --git a/crates/apl-cedarling/Cargo.toml b/crates/apl-cedarling/Cargo.toml new file mode 100644 index 00000000..b0c0dc8a --- /dev/null +++ b/crates/apl-cedarling/Cargo.toml @@ -0,0 +1,64 @@ +# Location: ./crates/apl-cedarling/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-cedarling — Cedarling-backed IdentityResolveHandler and +# PdpResolver implementations. +# +# Two modules in one crate because they share the same heavy dep +# (cedarling) and almost always run in the same deployment — an +# operator using Cedarling for identity resolution invariably also +# wants it for policy decisions, and the two consume the same +# `Cedarling` instance + policy store. +# +# # Why this crate isn't in default-members +# +# Cedarling pulls ~200 transitive dependencies (jsonwebtoken, reqwest, +# sparkv, datalogic-rs, flate2, regex, ahash, time, vfs, zip, …). To +# keep `cargo build` at the workspace root fast for the majority of +# iteration, the workspace excludes this crate from `default-members`. +# Build it explicitly with `cargo build -p apl-cedarling` or with +# `cargo build --workspace` for the full sweep. + +[package] +name = "apl-cedarling" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# Cedarling lives in the Janssen Project monorepo at the path +# `jans-cedarling/cedarling/` within that repo. We pin to a release +# tag rather than a branch so the dep tree stays reproducible across +# checkouts — bump the tag deliberately when we want a new version. +# +# `package = "cedarling"` tells Cargo which named crate to pick from +# the monorepo's multiple workspaces. `default-features = false` +# disables `grpc` (tonic+prost for Lock Server); Lock Server +# integration lands behind its own feature flag if/when we wire it. +# +# First build for new collaborators clones the Janssen monorepo (~200 +# transitive deps + cedarling's vendored workspace). Cached in +# ~/.cargo/git/ afterward. +cedarling = { git = "https://github.com/JanssenProject/jans", tag = "v2.1.0", package = "cedarling", default-features = false } + +# cedar-policy is a direct dep so we can name `cedar_policy::Decision` +# etc. in the resolver. Caret spec lets Cargo dedup to the same +# version Cedarling pulls (currently 4.11.0 transitively). +cedar-policy = "4" + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-cedarling/src/error.rs b/crates/apl-cedarling/src/error.rs new file mode 100644 index 00000000..d81fab69 --- /dev/null +++ b/crates/apl-cedarling/src/error.rs @@ -0,0 +1,30 @@ +// Location: ./crates/apl-cedarling/src/error.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build-time errors for constructing Cedarling-backed resolvers and +// handlers. Runtime errors flow through `PluginViolation` (for +// hook handlers) or `PdpError::Dispatch` (for the PDP path) — same +// pattern as `apl-pdp-cedar-direct`. + +use thiserror::Error; + +/// Errors that can occur while constructing a Cedarling-backed +/// resolver or handler from config. +#[non_exhaustive] +#[derive(Debug, Error)] +pub enum CedarlingPluginError { + /// The policy store file/URL couldn't be loaded. + #[error("failed to load policy store: {0}")] + PolicyStoreLoad(String), + + /// The bootstrap config was malformed or missing required fields. + #[error("invalid Cedarling bootstrap config: {0}")] + BootstrapConfig(String), + + /// Cedarling itself failed to initialize (JWKS unreachable, + /// schema validation failed, etc.). + #[error("Cedarling initialization failed: {0}")] + Init(String), +} diff --git a/crates/apl-cedarling/src/identity/mod.rs b/crates/apl-cedarling/src/identity/mod.rs new file mode 100644 index 00000000..1d7fdd8c --- /dev/null +++ b/crates/apl-cedarling/src/identity/mod.rs @@ -0,0 +1,31 @@ +// Location: ./crates/apl-cedarling/src/identity/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Cedarling-backed IdentityResolveHandler. +// +// Sub-step A scope: stub module. Actual implementation lands in +// sub-step B. +// +// # Planned shape +// +// ```ignore +// pub struct CedarlingIdentityResolver { +// cedarling: Arc, +// // optional: which sentinel action to use for identity-only +// // validation when no real policy decision is being made +// identity_action: String, +// } +// +// impl HookHandler for CedarlingIdentityResolver { +// async fn handle(&self, payload: &IdentityPayload, ...) -> ... { +// // Build TokenInputs from payload.raw_token() + headers +// // Call cedarling.authorize_multi_issuer with sentinel action +// // If decision is deny -> PluginResult::deny(violation) +// // If allow -> extract validated entities, map to +// // SubjectExtension / ClientExtension / WorkloadIdentity +// // and return modified payload +// } +// } +// ``` diff --git a/crates/apl-cedarling/src/lib.rs b/crates/apl-cedarling/src/lib.rs new file mode 100644 index 00000000..fb2ae21b --- /dev/null +++ b/crates/apl-cedarling/src/lib.rs @@ -0,0 +1,48 @@ +// Location: ./crates/apl-cedarling/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-cedarling — Cedarling-backed plugins for APL's two adjacent +// auth seams: +// +// * [`identity`] — `IdentityResolveHandler` that validates inbound +// JWTs through Cedarling and maps validated tokens into +// `SubjectExtension` / `ClientExtension`. Optionally runs an +// advisory Cedar policy check ("is this principal allowed at all") +// during the validation pass. +// * [`pdp`] — `PdpResolver` for `cedar:(...)` steps in APL routes. +// Mirrors the cedar-direct resolver but uses Cedarling's policy +// store loading + (eventually) Lock Server hooks instead of +// in-process `cedar-policy::PolicySet`. +// +// Both modules share a single `Cedarling` instance constructed from +// the same bootstrap config — operators using one almost always want +// the other, and double-loading the policy store / JWKS would be +// wasteful. +// +// # When to reach for this crate vs alternatives +// +// - **`apl-pdp-cedar-direct`** — simpler, ~5 transitive deps, +// policies as inline text. Use for tests, dev, or deployments +// that don't need policy-store signing / centralized management. +// - **`apl-identity-jwt`** (future) — JWT validation via the +// `jsonwebtoken` crate, no Cedar coupling, ~5 transitive deps. +// Use when you want lightweight identity without policy-driven +// identity decisions. +// - **`apl-cedarling`** (this crate) — heavy dep tree but gives you +// signed policy stores, Cedar-driven identity decisions, and +// (future) Lock Server fleet management. Use for production +// deployments with centralized policy management. +// +// # Sub-step A scope +// +// Module skeletons + crate wiring only. No actual Cedarling calls. +// Existence of this crate validates the dep-resolution cost honestly +// before we commit to the implementation. + +pub mod error; +pub mod identity; +pub mod pdp; + +pub use error::CedarlingPluginError; diff --git a/crates/apl-cedarling/src/pdp/mod.rs b/crates/apl-cedarling/src/pdp/mod.rs new file mode 100644 index 00000000..dad4ac3d --- /dev/null +++ b/crates/apl-cedarling/src/pdp/mod.rs @@ -0,0 +1,10 @@ +// Location: ./crates/apl-cedarling/src/pdp/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Cedarling-backed PdpResolver. + +pub mod resolver; + +pub use resolver::CedarlingPdpResolver; diff --git a/crates/apl-cedarling/src/pdp/resolver.rs b/crates/apl-cedarling/src/pdp/resolver.rs new file mode 100644 index 00000000..076f2854 --- /dev/null +++ b/crates/apl-cedarling/src/pdp/resolver.rs @@ -0,0 +1,374 @@ +// Location: ./crates/apl-cedarling/src/pdp/resolver.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CedarlingPdpResolver` — `PdpResolver` impl that delegates Cedar +// policy evaluation to a Cedarling instance. +// +// # Why Cedarling here instead of `apl-pdp-cedar-direct` +// +// Both call the same Cedar evaluator under the hood. The difference +// is the policy-store loading + management layer Cedarling provides: +// signed policy bundles, multi-policy stores keyed by ID, optional +// Lock Server integration for fleet-wide updates. Deployments that +// don't need any of that should reach for `apl-pdp-cedar-direct` +// instead — it's ~5 deps vs ~200. +// +// # Construction +// +// This resolver does NOT construct its own Cedarling instance. +// Cedarling holds shared state (JWT keys, entity store cache, +// optional Lock Server connection) that an entire deployment +// typically wants to share between identity resolution and PDP +// evaluation. The host builds one `Arc` at startup and +// hands the same handle to both this resolver and the +// (forthcoming) `CedarlingIdentityResolver`. +// +// # `authorize_unsigned` +// +// We use Cedarling's `authorize_unsigned` rather than +// `authorize_multi_issuer`. Reasoning: +// * APL has already done identity resolution by the time `cedar:` +// policy steps run — `Extensions.security.subject` / +// `.client` / `.caller_workload` are populated. +// * We build the principal entity from the `AttributeBag` directly, +// bypassing Cedarling's JWT-validation path entirely. +// * No sentinel-action workaround needed (the one we discussed for +// using `authorize_multi_issuer` purely for identity). + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use cedarling::{CedarEntityMapping, Cedarling, EntityData, RequestUnsigned}; +use serde_json::{json, Map, Value}; + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; + +/// `PdpResolver` that dispatches policy decisions to a Cedarling +/// instance. See module docs for when to prefer this over +/// `apl-pdp-cedar-direct`. +pub struct CedarlingPdpResolver { + /// Shared Cedarling instance — built once at host startup, + /// passed to both this resolver and the identity handler. + cedarling: Arc, + + /// The dialect this resolver registers under in the `PdpRouter`. + /// Defaults to `PdpDialect::Cedarling` (a distinct variant from + /// `Cedar`) so both `apl-pdp-cedar-direct` and this crate can + /// coexist in the same router and routes target each explicitly + /// via `cedar:(...)` vs `cedarling:(...)` step keys. + dialect: PdpDialect, + + /// Optional namespace prefix prepended to entity types built + /// from the bag (`"User"` → `"Jans::User"`). Matches the + /// `apl-pdp-cedar-direct` ergonomics; deployments with + /// namespaced schemas set this once at startup. + entity_namespace: Option, +} + +impl CedarlingPdpResolver { + /// Build a resolver around a pre-constructed Cedarling instance. + /// Cedarling construction is async and config-heavy + /// (`BootstrapConfig`, policy store loading); doing it inside + /// the resolver would force every call site into an async + /// context. The host owns the lifecycle. + pub fn new(cedarling: Arc) -> Self { + Self { + cedarling, + dialect: PdpDialect::Cedarling, + entity_namespace: None, + } + } + + pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { + self.dialect = dialect; + self + } + + pub fn with_entity_namespace(mut self, namespace: impl Into) -> Self { + self.entity_namespace = Some(namespace.into()); + self + } +} + +#[async_trait] +impl PdpResolver for CedarlingPdpResolver { + fn dialect(&self) -> PdpDialect { + self.dialect.clone() + } + + async fn evaluate( + &self, + call: &PdpCall, + bag: &AttributeBag, + ) -> Result { + let map = call.args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedarling: cedar:() args must be a mapping with action/resource keys" + .to_string(), + ) + })?; + + let action = yaml_string(map, "action").ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.action missing or not a string".into()) + })?; + + let resource_value = map + .get(serde_yaml::Value::String("resource".to_string())) + .ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.resource missing".into()) + })?; + let resource = build_resource_entity_data(resource_value)?; + + let principal = + build_principal_entity_data(bag, self.entity_namespace.as_deref())?; + + let context = map + .get(serde_yaml::Value::String("context".to_string())) + .map(|v| serde_json::to_value(v)) + .transpose() + .map_err(|e| { + PdpError::Dispatch(format!( + "cedarling: cedar:() args.context not JSON-representable: {e}" + )) + })? + .unwrap_or(Value::Object(Map::new())); + + let request = RequestUnsigned { + principal: Some(principal), + action, + resource, + context, + }; + + let result = self.cedarling.authorize_unsigned(request).await.map_err(|e| { + PdpError::Dispatch(format!("cedarling: authorize_unsigned failed: {e}")) + })?; + + Ok(translate_authorize_result(&result)) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Build the Cedarling principal entity from the attribute bag. Same +/// claim shape as `apl-pdp-cedar-direct`: +/// +/// * `subject.id` → entity id (required) +/// * `subject.type` → entity type ("User" default) +/// * `role.=true` → attrs.roles : Set +/// * `perm.=true` → attrs.permissions : Set +/// * `claim.=v` → attrs.claims. = v +/// * `subject.teams` → attrs.teams : Set +/// +/// Returns `EntityData` (Cedarling's JSON-shaped entity carrier), +/// which Cedarling converts internally to a `cedar_policy::Entity`. +fn build_principal_entity_data( + bag: &AttributeBag, + namespace: Option<&str>, +) -> Result { + let id = bag + .get_string("subject.id") + .ok_or_else(|| { + PdpError::Dispatch( + "cedarling: cedar request needs a principal but bag has no `subject.id` — \ + install an identity-hook plugin upstream of APL policy" + .to_string(), + ) + })? + .to_string(); + + let kind = bag.get_string("subject.type").unwrap_or("User"); + let entity_type = qualify_type(kind, namespace); + + let mut attributes: HashMap = HashMap::new(); + attributes.insert("id".to_string(), json!(id)); + attributes.insert("type".to_string(), json!(kind)); + + let roles = collect_prefixed_bools(bag, "role."); + attributes.insert("roles".to_string(), json!(roles)); + + let permissions = collect_prefixed_bools(bag, "perm."); + attributes.insert("permissions".to_string(), json!(permissions)); + + let teams: Vec = bag + .get_string_set("subject.teams") + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + attributes.insert("teams".to_string(), json!(teams)); + + let claims = collect_claims(bag); + attributes.insert("claims".to_string(), Value::Object(claims)); + + Ok(EntityData { + cedar_mapping: CedarEntityMapping { + entity_type, + id, + }, + attributes, + }) +} + +/// Build the resource entity from the policy author's `args.resource` +/// block: +/// +/// ```yaml +/// resource: +/// type: Document # required +/// id: doc-42 # required +/// attributes: # optional +/// classification: internal +/// ``` +fn build_resource_entity_data( + resource_args: &serde_yaml::Value, +) -> Result { + let map = resource_args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedarling: cedar:() args.resource must be a mapping".to_string(), + ) + })?; + let entity_type = yaml_string(map, "type").ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.resource.type missing".to_string()) + })?; + let id = yaml_string(map, "id").ok_or_else(|| { + PdpError::Dispatch("cedarling: cedar:() args.resource.id missing".to_string()) + })?; + + let mut attributes: HashMap = HashMap::new(); + if let Some(attrs_value) = map.get(serde_yaml::Value::String("attributes".to_string())) + { + let attrs_json: Value = serde_json::to_value(attrs_value).map_err(|e| { + PdpError::Dispatch(format!( + "cedarling: cedar:() args.resource.attributes not JSON-representable: {e}" + )) + })?; + if let Value::Object(map) = attrs_json { + for (k, v) in map { + attributes.insert(k, v); + } + } + } + + Ok(EntityData { + cedar_mapping: CedarEntityMapping { + entity_type, + id, + }, + attributes, + }) +} + +/// Translate Cedarling's `AuthorizeResult` into APL's `PdpDecision`. +/// Mirrors `apl-pdp-cedar-direct`'s decision-translation logic since +/// both crates ultimately read the same `cedar_policy::Response`. +/// Fail-closed on diagnostic errors. +fn translate_authorize_result(result: &cedarling::AuthorizeResult) -> PdpDecision { + use cedar_policy::Decision as CedarDecision; + let response = &result.response; + let diagnostics = response.diagnostics(); + + let firing_policies: Vec = diagnostics + .reason() + .map(|pid| pid.to_string()) + .collect(); + + let errors: Vec = diagnostics.errors().map(|e| e.to_string()).collect(); + + // Cedar evaluation errors → fail-closed deny. Same rule as + // `apl-pdp-cedar-direct`: any runtime error during evaluation + // produces an untrustworthy decision, so we override to deny. + if !errors.is_empty() { + let reason = format!( + "Cedar evaluation produced errors (fail-closed): {}", + errors.join("; ") + ); + let rule_source = firing_policies + .first() + .cloned() + .unwrap_or_else(|| "cedar.evaluation_error".to_string()); + return PdpDecision { + decision: Decision::Deny { + reason: Some(reason), + rule_source, + }, + diagnostics: firing_policies, + }; + } + + let decision = match response.decision() { + CedarDecision::Allow => Decision::Allow, + CedarDecision::Deny => { + let reason = if firing_policies.is_empty() { + "no Cedar permit policy matched the request".to_string() + } else { + format!("denied by Cedar policy: {}", firing_policies.join(", ")) + }; + let rule_source = firing_policies + .first() + .cloned() + .unwrap_or_else(|| "cedar.default_deny".to_string()); + Decision::Deny { + reason: Some(reason), + rule_source, + } + } + }; + + PdpDecision { + decision, + diagnostics: firing_policies, + } +} + +// ----- Small helpers, mirror cedar-direct ----- + +fn qualify_type(bare: &str, namespace: Option<&str>) -> String { + match namespace { + Some(ns) if !ns.is_empty() => format!("{ns}::{bare}"), + _ => bare.to_string(), + } +} + +fn collect_prefixed_bools(bag: &AttributeBag, prefix: &str) -> Vec { + use std::collections::HashSet; + let mut out: HashSet = HashSet::new(); + for (key, value) in bag.iter() { + if let Some(name) = key.strip_prefix(prefix) { + if matches!(value, AttributeValue::Bool(true)) { + out.insert(name.to_string()); + } + } + } + let mut v: Vec = out.into_iter().collect(); + v.sort(); + v +} + +fn collect_claims(bag: &AttributeBag) -> Map { + let mut out = Map::new(); + for (key, value) in bag.iter() { + if let Some(name) = key.strip_prefix("claim.") { + let v = match value { + AttributeValue::Bool(b) => json!(*b), + AttributeValue::Int(i) => json!(*i), + AttributeValue::Float(f) => json!(*f), + AttributeValue::String(s) => json!(s), + AttributeValue::StringSet(set) => json!(set.iter().collect::>()), + }; + out.insert(name.to_string(), v); + } + } + out +} + +fn yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_string()))? + .as_str() + .map(|s| s.to_string()) +} diff --git a/crates/apl-cedarling/tests/pdp_basic.rs b/crates/apl-cedarling/tests/pdp_basic.rs new file mode 100644 index 00000000..0fee5f07 --- /dev/null +++ b/crates/apl-cedarling/tests/pdp_basic.rs @@ -0,0 +1,166 @@ +// Location: ./crates/apl-cedarling/tests/pdp_basic.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Basic e2e for `CedarlingPdpResolver`: build a Cedarling instance +// against an inline policy store, dispatch a `cedar:` call through +// the resolver, assert the allow/deny path. +// +// This test exercises the full Cedarling stack — bootstrap config +// parsing, policy store loading, schema validation, Cedar evaluation, +// response translation. The `policy-store_no_trusted_issuers.yaml` +// pattern (no trusted JWT issuers configured) is what makes +// `authorize_unsigned` viable for us — Cedarling skips its JWT +// validation path entirely when there are no trusted issuers, so we +// can drive policy decisions purely from the bag-built entities. + +use std::sync::Arc; + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; + +use apl_cedarling::pdp::CedarlingPdpResolver; +use cedarling::{BootstrapConfig, Cedarling, PolicyStoreSource}; + +/// Minimal policy store: one permit policy that fires for +/// `Action::"read"` against any `Document` when the principal +/// carries `roles` containing "reader". The schema declares a +/// `Jans` namespace so policy IDs / entities resolve cleanly. +const POLICY_STORE_YAML: &str = r#" +cedar_version: v4.0.0 +policy_stores: + test-store-001: + cedar_version: v4.0.0 + name: "test" + policies: + 1: + description: reader-only read permit + creation_date: "2026-05-21T00:00:00.000000" + policy_content: + encoding: none + content_type: cedar + body: |- + permit( + principal, + action == Jans::Action::"read", + resource + )when{ + principal.roles.contains("reader") + }; + schema: + encoding: none + content_type: cedar + body: |- + namespace Jans { + entity Document = { "classification": String }; + entity User = { "roles": Set }; + action "read" appliesTo { + principal: [User], + resource: [Document], + context: {} + }; + } +"#; + +/// Build a Cedarling instance configured with the test policy store +/// and no trusted JWT issuers — so `authorize_unsigned` is the right +/// path (no token validation involved). +async fn build_cedarling() -> Arc { + let mut config = BootstrapConfig::default(); + config.application_name = "apl-cedarling-test".to_string(); + config.policy_store_config.source = + PolicyStoreSource::Yaml(POLICY_STORE_YAML.to_string()); + let cedarling = Cedarling::new(&config) + .await + .expect("Cedarling::new should succeed with valid config"); + Arc::new(cedarling) +} + +fn alice_with_reader_role() -> AttributeBag { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + bag.set("subject.type", "User"); + bag.set("role.reader", true); + bag +} + +fn bob_no_roles() -> AttributeBag { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "bob"); + bag.set("subject.type", "User"); + bag +} + +fn read_doc_call() -> PdpCall { + PdpCall { + // Route YAML `cedarling:(...)` produces this dialect. + // `apl-pdp-cedar-direct` registers under `PdpDialect::Cedar` + // so both resolvers can coexist in one PdpRouter. + dialect: PdpDialect::Cedarling, + args: serde_yaml::from_str( + r#" +action: 'Jans::Action::"read"' +resource: + type: Jans::Document + id: doc-42 + attributes: + classification: internal +"#, + ) + .unwrap(), + } +} + +#[tokio::test] +async fn reader_role_allows() { + let cedarling = build_cedarling().await; + let resolver = CedarlingPdpResolver::new(cedarling) + .with_entity_namespace("Jans"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_with_reader_role()) + .await + .expect("evaluate should succeed"); + assert!( + matches!(decision.decision, Decision::Allow), + "alice with role.reader should be allowed: got {:?}", + decision.decision, + ); +} + +#[tokio::test] +async fn missing_role_default_denies() { + let cedarling = build_cedarling().await; + let resolver = CedarlingPdpResolver::new(cedarling) + .with_entity_namespace("Jans"); + let decision = resolver + .evaluate(&read_doc_call(), &bob_no_roles()) + .await + .expect("evaluate should succeed"); + match decision.decision { + Decision::Deny { rule_source, .. } => { + // No permit fired → cedar.default_deny sentinel. + assert_eq!(rule_source, "cedar.default_deny"); + } + Decision::Allow => panic!("bob without reader role should be denied"), + } +} + +#[tokio::test] +async fn missing_subject_id_errors_clearly() { + let cedarling = build_cedarling().await; + let resolver = CedarlingPdpResolver::new(cedarling); + // Bag with no subject.id at all — resolver should fail + // construction of the principal entity with a clear error. + let bag = AttributeBag::new(); + let err = resolver + .evaluate(&read_doc_call(), &bag) + .await + .expect_err("missing subject.id should error"); + let msg = format!("{err:?}"); + assert!( + msg.contains("subject.id"), + "error should call out the missing key, got: {msg}", + ); +} diff --git a/crates/apl-cmf/Cargo.toml b/crates/apl-cmf/Cargo.toml new file mode 100644 index 00000000..141b4ea2 --- /dev/null +++ b/crates/apl-cmf/Cargo.toml @@ -0,0 +1,24 @@ +# Location: ./crates/apl-cmf/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-cmf — bridge from cpex-core typed extensions into apl-core's +# AttributeBag. The "where the policy vocabulary comes from" crate. + +[package] +name = "apl-cmf" +description = "APL ↔ CPEX bridge — extension → AttributeBag mapping" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } +serde_json = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/apl-cmf/src/agent.rs b/crates/apl-cmf/src/agent.rs new file mode 100644 index 00000000..1af89e19 --- /dev/null +++ b/crates/apl-cmf/src/agent.rs @@ -0,0 +1,68 @@ +// Location: ./crates/apl-cmf/src/agent.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// AgentExtension → AttributeBag. +// +// Namespace: +// agent.input : String +// agent.session_id : String +// agent.conversation_id : String +// agent.turn : Int +// agent.agent_id : String +// agent.parent_agent_id : String +// agent.conversation.summary : String +// agent.conversation.topics : StringSet + +use apl_core::AttributeBag; +use cpex_core::extensions::AgentExtension; +use std::collections::HashSet; + +pub fn extract_agent(agent: &AgentExtension, bag: &mut AttributeBag) { + if let Some(v) = &agent.input { bag.set("agent.input", v.clone()); } + if let Some(v) = &agent.session_id { bag.set("agent.session_id", v.clone()); } + if let Some(v) = &agent.conversation_id { bag.set("agent.conversation_id", v.clone()); } + if let Some(v) = agent.turn { bag.set("agent.turn", v as i64); } + if let Some(v) = &agent.agent_id { bag.set("agent.agent_id", v.clone()); } + if let Some(v) = &agent.parent_agent_id { bag.set("agent.parent_agent_id", v.clone()); } + if let Some(conv) = &agent.conversation { + if let Some(s) = &conv.summary { bag.set("agent.conversation.summary", s.clone()); } + if !conv.topics.is_empty() { + let topics: HashSet = conv.topics.iter().cloned().collect(); + bag.set("agent.conversation.topics", topics); + } + // `history: Vec` is deliberately not flattened — too unstructured. + // Policies wanting conversation history should call a plugin. + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::agent::ConversationContext; + + #[test] + fn populates_present_fields_only() { + let agent = AgentExtension { + session_id: Some("sess-1".into()), + conversation_id: Some("conv-9".into()), + turn: Some(3), + agent_id: Some("hr-agent".into()), + parent_agent_id: None, + conversation: Some(ConversationContext { + summary: Some("hr inquiry".into()), + topics: vec!["payroll".into(), "ssn".into()], + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_agent(&agent, &mut bag); + assert_eq!(bag.get_string("agent.session_id"), Some("sess-1")); + assert_eq!(bag.get_int("agent.turn"), Some(3)); + assert_eq!(bag.get_string("agent.conversation.summary"), Some("hr inquiry")); + assert!(bag.set_contains("agent.conversation.topics", "payroll")); + assert!(!bag.contains("agent.parent_agent_id")); + } +} diff --git a/crates/apl-cmf/src/capability_namespaces.rs b/crates/apl-cmf/src/capability_namespaces.rs new file mode 100644 index 00000000..f69543fb --- /dev/null +++ b/crates/apl-cmf/src/capability_namespaces.rs @@ -0,0 +1,312 @@ +// Location: ./crates/apl-cmf/src/capability_namespaces.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Capability → bag-namespace mapping for operator visibility. +// +// cpex-core's `filter_extensions(&ext, &caps)` decides which +// `Extensions` slots a plugin sees based on its declared +// `capabilities:` list. The CMF extractors then flatten those slots +// into bag attributes under well-known prefixes. This module is the +// bridge: given a capability name, return the bag-attribute prefixes +// it unlocks. Lets operators answer "what bag keys does this plugin +// see?" without reading source. +// +// # Scope +// +// Covers the `read_*` capabilities — those map to bag namespaces +// because the corresponding Extensions slots become bag attributes +// after extraction. Write capabilities (`append_labels`, +// `append_delegation`, `write_headers`) gate WRITE tokens, not +// readable state, so they don't appear here. +// +// # Source of truth +// +// All hard-coded strings — both capability names and bag-attribute +// prefixes — live in [`crate::constants`]. The table below +// references the constants rather than inlining strings, so a typo +// surfaces at compile time and the constants file is the single +// place to update names. + +use std::collections::HashSet; + +use crate::constants::*; + +/// Prefix mapping entry. `prefixes` lists the bag-attribute +/// namespace roots this capability unlocks. A prefix ending in `.` +/// means "any key starting with that root" (e.g. `role.` matches +/// `role.hr`). A prefix without a trailing `.` means an exact-match +/// key (e.g. `authenticated`). +struct CapabilityEntry { + name: &'static str, + prefixes: &'static [&'static str], +} + +/// The mapping table — single source of truth for which bag +/// namespaces a capability unlocks. Keep in sync with cpex-core's +/// `filter_extensions` rules and the per-extension extractor +/// modules (`security.rs`, `delegation.rs`, etc.). +const TABLE: &[CapabilityEntry] = &[ + // ----- Subject identity ----- + CapabilityEntry { + name: CAP_READ_SUBJECT, + // `read_subject` exposes id + type only; `authenticated` is + // derived from those being present. + prefixes: &[BAG_SUBJECT_ID, BAG_SUBJECT_TYPE, BAG_AUTHENTICATED], + }, + CapabilityEntry { + name: CAP_READ_ROLES, + // Implies the read_subject baseline + role.* prefix. + prefixes: &[ + BAG_ROLE_PREFIX, + BAG_SUBJECT_ID, + BAG_SUBJECT_TYPE, + BAG_AUTHENTICATED, + ], + }, + CapabilityEntry { + name: CAP_READ_PERMISSIONS, + prefixes: &[ + BAG_PERM_PREFIX, + BAG_SUBJECT_ID, + BAG_SUBJECT_TYPE, + BAG_AUTHENTICATED, + ], + }, + CapabilityEntry { + name: CAP_READ_TEAMS, + prefixes: &[ + BAG_SUBJECT_TEAMS, + BAG_SUBJECT_ID, + BAG_SUBJECT_TYPE, + BAG_AUTHENTICATED, + ], + }, + CapabilityEntry { + name: CAP_READ_CLAIMS, + prefixes: &[ + BAG_CLAIM_PREFIX, + BAG_SUBJECT_ID, + BAG_SUBJECT_TYPE, + BAG_AUTHENTICATED, + ], + }, + + // ----- Security extension (non-subject) ----- + CapabilityEntry { + // Labels are not extracted into discrete bag keys today — + // they live on `Extensions.security.labels` and plugins + // read them directly. APL's BagBuilder doesn't materialize + // a bag-readable label namespace yet; if it does, add the + // prefix constant + reference here. + name: CAP_READ_LABELS, + prefixes: &[], + }, + CapabilityEntry { + name: CAP_READ_CLIENT, + prefixes: &[BAG_CLIENT_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_WORKLOAD, + // Exposes both inbound caller workload AND this-host workload. + prefixes: &[BAG_WORKLOAD_PREFIX, BAG_CALLER_WORKLOAD_PREFIX], + }, + + // ----- Credential material — payload-only, no bag prefixes ----- + CapabilityEntry { + // Gates `Extensions.raw_credentials.inbound_tokens` — those + // tokens flow through plugin payloads (IdentityPayload, + // DelegationPayload), not into the bag. + name: CAP_READ_INBOUND_CREDENTIALS, + prefixes: &[], + }, + CapabilityEntry { + name: CAP_READ_DELEGATED_TOKENS, + prefixes: &[], + }, + + // ----- Delegation chain ----- + CapabilityEntry { + name: CAP_READ_DELEGATION, + prefixes: &[BAG_DELEGATION_PREFIX, BAG_DELEGATED], + }, + + // ----- Other extensions ----- + CapabilityEntry { + name: CAP_READ_AGENT, + prefixes: &[BAG_AGENT_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_META, + prefixes: &[BAG_META_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_REQUEST, + prefixes: &[BAG_REQUEST_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_HEADERS, + prefixes: &[ + BAG_HTTP_REQUEST_HEADERS_PREFIX, + BAG_HTTP_RESPONSE_HEADERS_PREFIX, + ], + }, + CapabilityEntry { + name: CAP_READ_LLM, + prefixes: &[BAG_LLM_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_MCP, + prefixes: &[BAG_MCP_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_COMPLETION, + prefixes: &[BAG_COMPLETION_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_PROVENANCE, + prefixes: &[BAG_PROVENANCE_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_FRAMEWORK, + prefixes: &[BAG_FRAMEWORK_PREFIX], + }, + CapabilityEntry { + name: CAP_READ_CUSTOM, + prefixes: &[BAG_CUSTOM_PREFIX], + }, +]; + +/// Bag-attribute prefixes a single capability unlocks. Returns an +/// empty slice for capabilities that don't expose bag-readable +/// state (write capabilities, or read capabilities for slots that +/// aren't extracted into the bag). Unknown capability names also +/// return empty — operators may declare custom caps the framework +/// doesn't recognize, and we don't want to imply they unlock +/// nothing in some "official" sense. +/// +/// A prefix ending in `.` matches any bag key starting with it +/// (e.g. `"role."` matches `"role.hr"`, `"role.admin"`). +/// A prefix without a trailing `.` matches the exact bag key +/// (e.g. `"authenticated"` matches only that bag key). +pub fn capability_namespaces(cap: &str) -> &'static [&'static str] { + TABLE + .iter() + .find(|e| e.name == cap) + .map(|e| e.prefixes) + .unwrap_or(&[]) +} + +/// Union of all bag-attribute prefixes unlocked by a set of +/// capabilities. Useful for operators answering "what can this +/// plugin see in the bag, given its declared caps?" without walking +/// the table per cap themselves. +pub fn unlocked_bag_prefixes(caps: &[String]) -> HashSet<&'static str> { + caps.iter() + .flat_map(|c| capability_namespaces(c).iter().copied()) + .collect() +} + +/// Every capability the framework recognizes for bag-namespace +/// purposes (excludes write caps and unknown ones). Useful for +/// completion / docs / config validation. +pub fn known_read_capabilities() -> impl Iterator { + TABLE.iter().map(|e| e.name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_subject_exposes_id_type_authenticated() { + let prefixes = capability_namespaces(CAP_READ_SUBJECT); + assert!(prefixes.contains(&BAG_SUBJECT_ID)); + assert!(prefixes.contains(&BAG_SUBJECT_TYPE)); + assert!(prefixes.contains(&BAG_AUTHENTICATED)); + } + + #[test] + fn read_roles_implies_subject_baseline_plus_role_prefix() { + let prefixes = capability_namespaces(CAP_READ_ROLES); + assert!(prefixes.contains(&BAG_ROLE_PREFIX)); + // Implied subject baseline. + assert!(prefixes.contains(&BAG_SUBJECT_ID)); + assert!(prefixes.contains(&BAG_AUTHENTICATED)); + } + + #[test] + fn read_delegation_exposes_delegation_namespace_and_delegated_flag() { + let prefixes = capability_namespaces(CAP_READ_DELEGATION); + assert!(prefixes.contains(&BAG_DELEGATION_PREFIX)); + assert!(prefixes.contains(&BAG_DELEGATED)); + } + + #[test] + fn read_headers_exposes_both_request_and_response_header_namespaces() { + let prefixes = capability_namespaces(CAP_READ_HEADERS); + assert!(prefixes.contains(&BAG_HTTP_REQUEST_HEADERS_PREFIX)); + assert!(prefixes.contains(&BAG_HTTP_RESPONSE_HEADERS_PREFIX)); + } + + #[test] + fn unknown_capability_returns_empty() { + assert!(capability_namespaces("read_nonsense").is_empty()); + } + + #[test] + fn write_capability_returns_empty() { + // Write caps don't expose bag-readable state. + assert!(capability_namespaces(CAP_APPEND_LABELS).is_empty()); + assert!(capability_namespaces(CAP_APPEND_DELEGATION).is_empty()); + assert!(capability_namespaces(CAP_WRITE_HEADERS).is_empty()); + } + + #[test] + fn payload_only_credential_caps_return_empty() { + // These caps gate Extensions slots that flow through plugin + // payloads, not bag attributes. + assert!(capability_namespaces(CAP_READ_INBOUND_CREDENTIALS).is_empty()); + assert!(capability_namespaces(CAP_READ_DELEGATED_TOKENS).is_empty()); + // read_labels too — labels aren't materialized into bag keys. + assert!(capability_namespaces(CAP_READ_LABELS).is_empty()); + } + + #[test] + fn unlocked_bag_prefixes_unions_multiple_caps() { + let caps = vec![CAP_READ_SUBJECT.to_string(), CAP_READ_ROLES.to_string()]; + let union = unlocked_bag_prefixes(&caps); + assert!(union.contains(BAG_SUBJECT_ID)); + assert!(union.contains(BAG_ROLE_PREFIX)); + // Deduplicates the shared subject baseline — only ONE + // entry for the common BAG_SUBJECT_ID even though both + // caps include it. + let baseline_count = union.iter().filter(|p| **p == BAG_SUBJECT_ID).count(); + assert_eq!(baseline_count, 1); + } + + #[test] + fn unlocked_bag_prefixes_skips_unknown_caps() { + let caps = vec![CAP_READ_SUBJECT.to_string(), "read_made_up".to_string()]; + let union = unlocked_bag_prefixes(&caps); + assert!(union.contains(BAG_SUBJECT_ID)); + // Unknown cap contributes nothing — no panic, no surprise key. + // read_subject contributes 3 entries; that's the total. + assert_eq!(union.len(), 3); + } + + #[test] + fn known_read_capabilities_returns_every_table_entry() { + let count = known_read_capabilities().count(); + // Sanity: substantial but bounded — table bloat would be + // a maintenance signal. + assert!(count > 10, "expected >10 known caps, got {count}"); + assert!(count < 50, "table grew unexpectedly to {count} entries"); + // Spot-check canonical names are present. + let names: HashSet<&str> = known_read_capabilities().collect(); + assert!(names.contains(CAP_READ_SUBJECT)); + assert!(names.contains(CAP_READ_META)); + assert!(names.contains(CAP_READ_DELEGATION)); + } +} diff --git a/crates/apl-cmf/src/completion.rs b/crates/apl-cmf/src/completion.rs new file mode 100644 index 00000000..6a8bab82 --- /dev/null +++ b/crates/apl-cmf/src/completion.rs @@ -0,0 +1,76 @@ +// Location: ./crates/apl-cmf/src/completion.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CompletionExtension → AttributeBag. +// +// Namespace: +// completion.stop_reason : String (snake_case: "end" | "return" | "call" | "max_tokens" | "stop_sequence") +// completion.model : String +// completion.raw_format : String +// completion.created_at : String +// completion.latency_ms : Int +// completion.tokens.input : Int +// completion.tokens.output : Int +// completion.tokens.total : Int + +use apl_core::AttributeBag; +use cpex_core::extensions::{CompletionExtension, StopReason}; + +pub fn extract_completion(c: &CompletionExtension, bag: &mut AttributeBag) { + if let Some(sr) = c.stop_reason { + bag.set("completion.stop_reason", stop_reason_str(sr)); + } + if let Some(tu) = &c.tokens { + bag.set("completion.tokens.input", tu.input_tokens as i64); + bag.set("completion.tokens.output", tu.output_tokens as i64); + bag.set("completion.tokens.total", tu.total_tokens as i64); + } + if let Some(v) = &c.model { bag.set("completion.model", v.clone()); } + if let Some(v) = &c.raw_format { bag.set("completion.raw_format", v.clone()); } + if let Some(v) = &c.created_at { bag.set("completion.created_at", v.clone()); } + if let Some(ms) = c.latency_ms { bag.set("completion.latency_ms", ms as i64); } +} + +fn stop_reason_str(sr: StopReason) -> &'static str { + match sr { + StopReason::End => "end", + StopReason::Return => "return", + StopReason::Call => "call", + StopReason::MaxTokens => "max_tokens", + StopReason::StopSequence => "stop_sequence", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::completion::TokenUsage; + + #[test] + fn stop_reason_serializes_as_snake_case_string() { + let c = CompletionExtension { + stop_reason: Some(StopReason::MaxTokens), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_completion(&c, &mut bag); + assert_eq!(bag.get_string("completion.stop_reason"), Some("max_tokens")); + } + + #[test] + fn tokens_flatten_to_nested_ints() { + let c = CompletionExtension { + tokens: Some(TokenUsage { input_tokens: 100, output_tokens: 50, total_tokens: 150 }), + latency_ms: Some(420), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_completion(&c, &mut bag); + assert_eq!(bag.get_int("completion.tokens.input"), Some(100)); + assert_eq!(bag.get_int("completion.tokens.output"), Some(50)); + assert_eq!(bag.get_int("completion.tokens.total"), Some(150)); + assert_eq!(bag.get_int("completion.latency_ms"), Some(420)); + } +} diff --git a/crates/apl-cmf/src/constants.rs b/crates/apl-cmf/src/constants.rs new file mode 100644 index 00000000..276fb4a6 --- /dev/null +++ b/crates/apl-cmf/src/constants.rs @@ -0,0 +1,113 @@ +// Location: ./crates/apl-cmf/src/constants.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// String constants used across apl-cmf — capability names cpex-core +// recognizes for `filter_extensions`, plus the bag-attribute +// prefixes APL extractors write under. Centralizing both makes the +// capability → bag namespace mapping in `capability_namespaces` a +// straight reference rather than a soup of inline strings, and +// gives operators / docs / tools one canonical place to read them +// from. +// +// # Source-of-truth invariants +// +// * `CAP_*` names match `cpex_core::extensions::filter::filter_extensions` +// verbatim. cpex-core is authoritative — if it changes a cap name, +// bump here and update the mapping table. +// * `BAG_*` prefixes match what the per-extension extractor modules +// (`security.rs`, `delegation.rs`, etc.) actually write into the +// bag. The extractor files still use string literals today; a +// future cleanup can refactor them to consume these constants to +// prevent drift. Tests in `capability_namespaces` flag the +// contract. + +// ===================================================================== +// Capability names — must match cpex-core's vocabulary +// ===================================================================== + +// ----- Subject identity (read) ----- +pub const CAP_READ_SUBJECT: &str = "read_subject"; +pub const CAP_READ_ROLES: &str = "read_roles"; +pub const CAP_READ_PERMISSIONS: &str = "read_permissions"; +pub const CAP_READ_TEAMS: &str = "read_teams"; +pub const CAP_READ_CLAIMS: &str = "read_claims"; + +// ----- Security extension (non-subject) ----- +pub const CAP_READ_LABELS: &str = "read_labels"; +pub const CAP_READ_CLIENT: &str = "read_client"; +pub const CAP_READ_WORKLOAD: &str = "read_workload"; + +// ----- Credential material — payload-only, no bag prefixes ----- +pub const CAP_READ_INBOUND_CREDENTIALS: &str = "read_inbound_credentials"; +pub const CAP_READ_DELEGATED_TOKENS: &str = "read_delegated_tokens"; + +// ----- Per-extension reads ----- +pub const CAP_READ_DELEGATION: &str = "read_delegation"; +pub const CAP_READ_AGENT: &str = "read_agent"; +pub const CAP_READ_META: &str = "read_meta"; +pub const CAP_READ_REQUEST: &str = "read_request"; +pub const CAP_READ_HEADERS: &str = "read_headers"; +pub const CAP_READ_LLM: &str = "read_llm"; +pub const CAP_READ_MCP: &str = "read_mcp"; +pub const CAP_READ_COMPLETION: &str = "read_completion"; +pub const CAP_READ_PROVENANCE: &str = "read_provenance"; +pub const CAP_READ_FRAMEWORK: &str = "read_framework"; +pub const CAP_READ_CUSTOM: &str = "read_custom"; + +// ----- Write tokens — don't unlock bag attributes ----- +pub const CAP_APPEND_LABELS: &str = "append_labels"; +pub const CAP_APPEND_DELEGATION: &str = "append_delegation"; +pub const CAP_WRITE_HEADERS: &str = "write_headers"; + +// ===================================================================== +// Bag-attribute prefixes (and exact-match keys) — must match what +// the apl-cmf extractor modules write. +// +// Prefixes ending in `.` match any key starting with them +// (e.g. `BAG_ROLE_PREFIX` matches `role.hr`, `role.admin`). +// Prefixes WITHOUT a trailing `.` match the exact bag key +// (e.g. `BAG_AUTHENTICATED` matches only `authenticated`). +// ===================================================================== + +// ----- Subject ----- +pub const BAG_SUBJECT_ID: &str = "subject.id"; +pub const BAG_SUBJECT_TYPE: &str = "subject.type"; +pub const BAG_SUBJECT_TEAMS: &str = "subject.teams"; +pub const BAG_AUTHENTICATED: &str = "authenticated"; +pub const BAG_ROLE_PREFIX: &str = "role."; +pub const BAG_PERM_PREFIX: &str = "perm."; +pub const BAG_TEAM_PREFIX: &str = "team."; +pub const BAG_CLAIM_PREFIX: &str = "claim."; + +// ----- Payload (args / result) ----- +// +// These are the dotted-prefix forms used when apl-cmf::payload flattens +// the request's args object and the upstream's result object into the +// bag. APL predicates / Cedar `${args.X}` substitutions / OPA `input.X` +// paths all resolve through these. +pub const BAG_ARGS_PREFIX: &str = "args."; +pub const BAG_RESULT_PREFIX: &str = "result."; + +// ----- Client + workload ----- +pub const BAG_CLIENT_PREFIX: &str = "client."; +pub const BAG_WORKLOAD_PREFIX: &str = "workload."; +pub const BAG_CALLER_WORKLOAD_PREFIX: &str = "caller_workload."; + +// ----- Delegation ----- +pub const BAG_DELEGATION_PREFIX: &str = "delegation."; +pub const BAG_DELEGATED: &str = "delegated"; + +// ----- Other extensions ----- +pub const BAG_AGENT_PREFIX: &str = "agent."; +pub const BAG_META_PREFIX: &str = "meta."; +pub const BAG_REQUEST_PREFIX: &str = "request."; +pub const BAG_HTTP_REQUEST_HEADERS_PREFIX: &str = "http.request_headers."; +pub const BAG_HTTP_RESPONSE_HEADERS_PREFIX: &str = "http.response_headers."; +pub const BAG_LLM_PREFIX: &str = "llm."; +pub const BAG_MCP_PREFIX: &str = "mcp."; +pub const BAG_COMPLETION_PREFIX: &str = "completion."; +pub const BAG_PROVENANCE_PREFIX: &str = "provenance."; +pub const BAG_FRAMEWORK_PREFIX: &str = "framework."; +pub const BAG_CUSTOM_PREFIX: &str = "custom."; diff --git a/crates/apl-cmf/src/custom.rs b/crates/apl-cmf/src/custom.rs new file mode 100644 index 00000000..a933fbc3 --- /dev/null +++ b/crates/apl-cmf/src/custom.rs @@ -0,0 +1,42 @@ +// Location: ./crates/apl-cmf/src/custom.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `Extensions.custom` (HashMap) → AttributeBag. +// +// Open-ended user namespace. Each top-level key becomes `custom.`, +// and nested objects flatten through the same JSON walker as args/result. +// Lets a host stuff arbitrary policy-relevant data into the bag without +// needing a new extension type. +// +// Namespace: +// custom. : Bool | Int | Float | String | StringSet + +use apl_core::AttributeBag; +use serde_json::Value; +use std::collections::HashMap; + +pub fn extract_custom(custom: &HashMap, bag: &mut AttributeBag) { + for (k, v) in custom { + crate::payload::walk(v, &format!("custom.{}", k), bag); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn custom_keys_flatten_under_custom_namespace() { + let mut custom = HashMap::new(); + custom.insert("feature_flag".into(), json!(true)); + custom.insert("tenant".into(), json!({ "id": "acme", "tier": "enterprise" })); + let mut bag = AttributeBag::new(); + extract_custom(&custom, &mut bag); + assert_eq!(bag.get_bool("custom.feature_flag"), Some(true)); + assert_eq!(bag.get_string("custom.tenant.id"), Some("acme")); + assert_eq!(bag.get_string("custom.tenant.tier"), Some("enterprise")); + } +} diff --git a/crates/apl-cmf/src/delegation.rs b/crates/apl-cmf/src/delegation.rs new file mode 100644 index 00000000..50d8f6b9 --- /dev/null +++ b/crates/apl-cmf/src/delegation.rs @@ -0,0 +1,88 @@ +// Location: ./crates/apl-cmf/src/delegation.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// DelegationExtension → AttributeBag. +// +// Namespace map: +// +// del.depth → delegation.depth : Int +// del.delegated → delegation.delegated, delegated : Bool +// del.origin_subject_id → delegation.origin_subject_id : String +// del.actor_subject_id → delegation.actor_subject_id : String +// del.age_seconds → delegation.age_seconds : Float +// +// Per-hop fields (scopes, audience, strategy) are not flattened into the +// bag. Policies that need that depth call out to a plugin or PDP; the +// bag stays scalar. + +use apl_core::AttributeBag; +use cpex_core::extensions::DelegationExtension; + +/// Flatten a `DelegationExtension` into the bag. +pub fn extract_delegation(del: &DelegationExtension, bag: &mut AttributeBag) { + bag.set("delegation.depth", del.depth as i64); + bag.set("delegation.delegated", del.delegated); + // Top-level alias — DSL idiom is `require(!delegated)`, unprefixed. + bag.set("delegated", del.delegated); + + if let Some(origin) = &del.origin_subject_id { + bag.set("delegation.origin_subject_id", origin.clone()); + } + if let Some(actor) = &del.actor_subject_id { + bag.set("delegation.actor_subject_id", actor.clone()); + } + bag.set("delegation.age_seconds", del.age_seconds); +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::{DelegationHop, DelegationStrategy}; + + #[test] + fn empty_delegation_sets_zero_depth_and_delegated_false() { + let del = DelegationExtension::default(); + let mut bag = AttributeBag::new(); + extract_delegation(&del, &mut bag); + assert_eq!(bag.get_int("delegation.depth"), Some(0)); + assert_eq!(bag.get_bool("delegation.delegated"), Some(false)); + assert_eq!(bag.get_bool("delegated"), Some(false)); + // Optional fields stay absent. + assert!(!bag.contains("delegation.origin_subject_id")); + assert!(!bag.contains("delegation.actor_subject_id")); + } + + #[test] + fn populated_chain_produces_attributes() { + let mut del = DelegationExtension { + origin_subject_id: Some("alice".into()), + actor_subject_id: Some("service-b".into()), + age_seconds: 12.5, + ..Default::default() + }; + del.append_hop(DelegationHop { + subject_id: "alice".into(), + audience: Some("service-b".into()), + scopes_granted: vec!["read".into()], + strategy: Some(DelegationStrategy::TokenExchange), + ..Default::default() + }); + del.append_hop(DelegationHop { + subject_id: "service-b".into(), + audience: Some("service-c".into()), + scopes_granted: vec!["read".into()], + ..Default::default() + }); + + let mut bag = AttributeBag::new(); + extract_delegation(&del, &mut bag); + assert_eq!(bag.get_int("delegation.depth"), Some(2)); + assert_eq!(bag.get_bool("delegation.delegated"), Some(true)); + assert_eq!(bag.get_bool("delegated"), Some(true)); + assert_eq!(bag.get_string("delegation.origin_subject_id"), Some("alice")); + assert_eq!(bag.get_string("delegation.actor_subject_id"), Some("service-b")); + assert_eq!(bag.get_float("delegation.age_seconds"), Some(12.5)); + } +} diff --git a/crates/apl-cmf/src/extensions_bridge.rs b/crates/apl-cmf/src/extensions_bridge.rs new file mode 100644 index 00000000..6c650aca --- /dev/null +++ b/crates/apl-cmf/src/extensions_bridge.rs @@ -0,0 +1,94 @@ +// Location: ./crates/apl-cmf/src/extensions_bridge.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Unified entry point: take an `Extensions` container, dispatch each +// present slot to its per-extension extractor. +// +// This is the function `apl-cpex` will call at hook time after assembling +// `Extensions` from the request. It guarantees every slot that's present +// gets bridged, so a new extension type that adds an extractor module +// shows up in the bag automatically. + +use apl_core::AttributeBag; +use cpex_core::extensions::Extensions; + +use crate::{ + agent::extract_agent, completion::extract_completion, custom::extract_custom, + delegation::extract_delegation, framework::extract_framework, http::extract_http, + llm::extract_llm, mcp::extract_mcp, meta::extract_meta, provenance::extract_provenance, + request::extract_request, security::extract_security, +}; + +/// Flatten every present slot in `Extensions` into `bag`. +pub fn extract_extensions(ext: &Extensions, bag: &mut AttributeBag) { + if let Some(v) = &ext.security { extract_security(v, bag); } + if let Some(v) = &ext.delegation { extract_delegation(v, bag); } + if let Some(v) = &ext.agent { extract_agent(v, bag); } + if let Some(v) = &ext.meta { extract_meta(v, bag); } + if let Some(v) = &ext.request { extract_request(v, bag); } + if let Some(v) = &ext.http { extract_http(v, bag); } + if let Some(v) = &ext.llm { extract_llm(v, bag); } + if let Some(v) = &ext.mcp { extract_mcp(v, bag); } + if let Some(v) = &ext.completion { extract_completion(v, bag); } + if let Some(v) = &ext.provenance { extract_provenance(v, bag); } + if let Some(v) = &ext.framework { extract_framework(v, bag); } + if let Some(v) = &ext.custom { extract_custom(v, bag); } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::{ + AgentExtension, DelegationExtension, LLMExtension, MetaExtension, SecurityExtension, + SubjectExtension, + }; + use std::collections::HashSet; + use std::sync::Arc; + + #[test] + fn dispatches_every_present_slot() { + let mut ext = Extensions::default(); + ext.security = Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + roles: HashSet::from(["hr".to_string()]), + ..Default::default() + }), + ..Default::default() + })); + ext.delegation = Some(Arc::new(DelegationExtension::default())); + ext.agent = Some(Arc::new(AgentExtension { + session_id: Some("sess-1".into()), + ..Default::default() + })); + ext.meta = Some(Arc::new(MetaExtension { + tags: HashSet::from(["pii".to_string()]), + ..Default::default() + })); + ext.llm = Some(Arc::new(LLMExtension { + model_id: Some("gpt-4".into()), + ..Default::default() + })); + + let mut bag = AttributeBag::new(); + extract_extensions(&ext, &mut bag); + + // One assertion per namespace — proves the dispatch reached each. + assert_eq!(bag.get_string("subject.id"), Some("alice")); + assert_eq!(bag.get_bool("role.hr"), Some(true)); + assert_eq!(bag.get_int("delegation.depth"), Some(0)); + assert_eq!(bag.get_string("agent.session_id"), Some("sess-1")); + assert!(bag.set_contains("meta.tags", "pii")); + assert_eq!(bag.get_string("llm.model_id"), Some("gpt-4")); + } + + #[test] + fn absent_slots_skipped_no_panic() { + let ext = Extensions::default(); + let mut bag = AttributeBag::new(); + extract_extensions(&ext, &mut bag); + assert!(bag.is_empty()); + } +} diff --git a/crates/apl-cmf/src/framework.rs b/crates/apl-cmf/src/framework.rs new file mode 100644 index 00000000..ccd39e55 --- /dev/null +++ b/crates/apl-cmf/src/framework.rs @@ -0,0 +1,55 @@ +// Location: ./crates/apl-cmf/src/framework.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// FrameworkExtension → AttributeBag. +// +// Namespace: +// framework.framework : String ("langchain", "crewai", ...) +// framework.framework_version : String +// framework.node_id : String +// framework.graph_id : String +// framework.metadata. : various (JSON walker — same as args) + +use apl_core::AttributeBag; +use cpex_core::extensions::FrameworkExtension; + +pub fn extract_framework(f: &FrameworkExtension, bag: &mut AttributeBag) { + if let Some(v) = &f.framework { bag.set("framework.framework", v.clone()); } + if let Some(v) = &f.framework_version { bag.set("framework.framework_version", v.clone()); } + if let Some(v) = &f.node_id { bag.set("framework.node_id", v.clone()); } + if let Some(v) = &f.graph_id { bag.set("framework.graph_id", v.clone()); } + // metadata is a HashMap — flatten the same way args/result do. + for (k, v) in &f.metadata { + crate::payload::walk(v, &format!("framework.metadata.{}", k), bag); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + #[test] + fn nested_metadata_flattens() { + let f = FrameworkExtension { + framework: Some("langchain".into()), + framework_version: Some("0.1.42".into()), + node_id: Some("retriever".into()), + metadata: HashMap::from([ + ("chain_id".to_string(), json!("abc")), + ("step".to_string(), json!(7)), + ("flags".to_string(), json!({ "verbose": true })), + ]), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_framework(&f, &mut bag); + assert_eq!(bag.get_string("framework.framework"), Some("langchain")); + assert_eq!(bag.get_string("framework.metadata.chain_id"), Some("abc")); + assert_eq!(bag.get_int("framework.metadata.step"), Some(7)); + assert_eq!(bag.get_bool("framework.metadata.flags.verbose"), Some(true)); + } +} diff --git a/crates/apl-cmf/src/http.rs b/crates/apl-cmf/src/http.rs new file mode 100644 index 00000000..60d84565 --- /dev/null +++ b/crates/apl-cmf/src/http.rs @@ -0,0 +1,45 @@ +// Location: ./crates/apl-cmf/src/http.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// HttpExtension → AttributeBag. +// +// Header names are lowercased in the bag (HTTP is case-insensitive). A +// policy author writing `http.request_headers.authorization` doesn't need +// to remember the original case. +// +// Namespace: +// http.request_headers. : String (lowercased name) +// http.response_headers. : String (lowercased name) + +use apl_core::AttributeBag; +use cpex_core::extensions::HttpExtension; + +pub fn extract_http(http: &HttpExtension, bag: &mut AttributeBag) { + for (k, v) in &http.request_headers { + bag.set(format!("http.request_headers.{}", k.to_lowercase()), v.clone()); + } + for (k, v) in &http.response_headers { + bag.set(format!("http.response_headers.{}", k.to_lowercase()), v.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headers_lowercased_in_bag() { + let mut http = HttpExtension::default(); + http.set_request_header("Authorization", "Bearer xyz"); + http.set_request_header("X-Trace-Id", "abc-123"); + http.set_response_header("Content-Type", "application/json"); + + let mut bag = AttributeBag::new(); + extract_http(&http, &mut bag); + assert_eq!(bag.get_string("http.request_headers.authorization"), Some("Bearer xyz")); + assert_eq!(bag.get_string("http.request_headers.x-trace-id"), Some("abc-123")); + assert_eq!(bag.get_string("http.response_headers.content-type"), Some("application/json")); + } +} diff --git a/crates/apl-cmf/src/lib.rs b/crates/apl-cmf/src/lib.rs new file mode 100644 index 00000000..dcbeda91 --- /dev/null +++ b/crates/apl-cmf/src/lib.rs @@ -0,0 +1,137 @@ +// Location: ./crates/apl-cmf/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-cmf — bridges typed cpex-core extensions into apl-core's flat +// AttributeBag. This is where the *attribute vocabulary* APL policy +// authors write against gets defined. +// +// Layering (see docs/specs/apl-design.md §4): +// +// cpex-core : typed extension data (SecurityExtension, …) +// apl-cmf : ←── this crate, flat-key bridge +// apl-core : language IR + evaluator (AttributeBag, predicates, pipelines) +// apl-cpex : runtime adapter (hooks, PluginInvoker, PdpResolver) +// +// The crate is intentionally simple: each bridge is a pure function that +// reads its typed source and writes flat keys into a borrowed bag. No +// async, no I/O. Composition is via the convenience `BagBuilder`. +// +// Attribute namespace contract (each module owns the detail comment): +// SecurityExtension.subject → subject.*, role.*, perm.*, claim.*, authenticated +// SecurityExtension.client → client.*, client.role.*, client.perm.*, client.claim.* +// SecurityExtension.caller_workload → caller_workload.* (inbound attested peer) +// SecurityExtension.this_workload → this_workload.* (our own attested identity — +// not `agent.*`, which is `AgentExtension`) +// SecurityExtension → security.labels, security.classification, auth_method +// DelegationExtension → delegation.*, delegated +// AgentExtension → agent.* (session, conversation, lineage) +// MetaExtension → meta.* +// RequestExtension → request.* +// HttpExtension → http.request_headers.*, http.response_headers.* +// LLMExtension → llm.* +// MCPExtension → mcp.tool.*, mcp.resource.*, mcp.prompt.* +// CompletionExtension → completion.* +// ProvenanceExtension → provenance.* +// FrameworkExtension → framework.* (incl. framework.metadata.*) +// Extensions.custom → custom.* +// Request args object → args.* +// Response result object → result.* + +pub mod agent; +pub mod capability_namespaces; +pub mod completion; +pub mod constants; +pub mod custom; +pub mod delegation; +pub mod extensions_bridge; +pub mod framework; +pub mod http; +pub mod llm; +pub mod mcp; +pub mod meta; +pub mod payload; +pub mod provenance; +pub mod request; +pub mod security; + +pub use agent::extract_agent; +pub use capability_namespaces::{ + capability_namespaces, known_read_capabilities, unlocked_bag_prefixes, +}; +pub use completion::extract_completion; +pub use custom::extract_custom; +pub use delegation::extract_delegation; +pub use extensions_bridge::extract_extensions; +pub use framework::extract_framework; +pub use http::extract_http; +pub use llm::extract_llm; +pub use mcp::extract_mcp; +pub use meta::extract_meta; +pub use payload::{extract_args, extract_result}; +pub use provenance::extract_provenance; +pub use request::extract_request; +pub use security::{extract_client, extract_security, extract_workload}; + +use apl_core::AttributeBag; +use cpex_core::extensions::{DelegationExtension, Extensions, SecurityExtension}; + +/// Fluent builder that composes the typed sources into a single bag. +/// +/// Lets the host (apl-cpex) write: +/// ```ignore +/// let bag = BagBuilder::new() +/// .with_security(&sec) +/// .with_delegation(&del) +/// .with_args(&payload.args) +/// .build(); +/// ``` +/// +/// Order of `with_*` calls is irrelevant — keys live in disjoint namespaces. +#[derive(Default)] +pub struct BagBuilder { + bag: AttributeBag, +} + +impl BagBuilder { + pub fn new() -> Self { Self::default() } + + pub fn with_security(mut self, sec: &SecurityExtension) -> Self { + extract_security(sec, &mut self.bag); + self + } + + pub fn with_delegation(mut self, del: &DelegationExtension) -> Self { + extract_delegation(del, &mut self.bag); + self + } + + /// Bridge every present slot in an `Extensions` container at once — + /// security, delegation, agent, meta, request, http, llm, mcp, + /// completion, provenance, framework, custom. + pub fn with_extensions(mut self, ext: &Extensions) -> Self { + extract_extensions(ext, &mut self.bag); + self + } + + pub fn with_args(mut self, args: &serde_json::Value) -> Self { + extract_args(args, &mut self.bag); + self + } + + pub fn with_result(mut self, result: &serde_json::Value) -> Self { + extract_result(result, &mut self.bag); + self + } + + /// Set the route key under `route.key` for policy predicates that + /// branch on which route is running (mostly useful in default/policy + /// bundles applied across routes). + pub fn with_route_key(mut self, route_key: impl Into) -> Self { + self.bag.set("route.key", route_key.into()); + self + } + + pub fn build(self) -> AttributeBag { self.bag } +} diff --git a/crates/apl-cmf/src/llm.rs b/crates/apl-cmf/src/llm.rs new file mode 100644 index 00000000..0dbe3332 --- /dev/null +++ b/crates/apl-cmf/src/llm.rs @@ -0,0 +1,44 @@ +// Location: ./crates/apl-cmf/src/llm.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// LLMExtension → AttributeBag. +// +// Namespace: +// llm.model_id : String +// llm.provider : String +// llm.capabilities : StringSet + +use apl_core::AttributeBag; +use cpex_core::extensions::LLMExtension; +use std::collections::HashSet; + +pub fn extract_llm(llm: &LLMExtension, bag: &mut AttributeBag) { + if let Some(v) = &llm.model_id { bag.set("llm.model_id", v.clone()); } + if let Some(v) = &llm.provider { bag.set("llm.provider", v.clone()); } + if !llm.capabilities.is_empty() { + let caps: HashSet = llm.capabilities.iter().cloned().collect(); + bag.set("llm.capabilities", caps); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_model_and_capabilities() { + let llm = LLMExtension { + model_id: Some("gpt-4".into()), + provider: Some("openai".into()), + capabilities: vec!["tool_use".into(), "vision".into()], + }; + let mut bag = AttributeBag::new(); + extract_llm(&llm, &mut bag); + assert_eq!(bag.get_string("llm.model_id"), Some("gpt-4")); + assert_eq!(bag.get_string("llm.provider"), Some("openai")); + assert!(bag.set_contains("llm.capabilities", "tool_use")); + assert!(bag.set_contains("llm.capabilities", "vision")); + } +} diff --git a/crates/apl-cmf/src/mcp.rs b/crates/apl-cmf/src/mcp.rs new file mode 100644 index 00000000..9327dd1b --- /dev/null +++ b/crates/apl-cmf/src/mcp.rs @@ -0,0 +1,92 @@ +// Location: ./crates/apl-cmf/src/mcp.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MCPExtension → AttributeBag. +// +// Tool, resource, and prompt metadata each flatten under their own sub-namespace. +// Schemas and annotations are deliberately NOT flattened — they're free-form +// JSON; policies that need them should call a plugin. +// +// Namespace: +// mcp.tool.name : String (always set if tool present) +// mcp.tool.title : String +// mcp.tool.description : String +// mcp.tool.server_id : String +// mcp.tool.namespace : String +// mcp.resource.uri : String (always set if resource present) +// mcp.resource.name : String +// mcp.resource.description: String +// mcp.resource.mime_type : String +// mcp.resource.server_id : String +// mcp.prompt.name : String (always set if prompt present) +// mcp.prompt.description : String +// mcp.prompt.server_id : String + +use apl_core::AttributeBag; +use cpex_core::extensions::MCPExtension; + +pub fn extract_mcp(mcp: &MCPExtension, bag: &mut AttributeBag) { + if let Some(tool) = &mcp.tool { + bag.set("mcp.tool.name", tool.name.clone()); + if let Some(v) = &tool.title { bag.set("mcp.tool.title", v.clone()); } + if let Some(v) = &tool.description { bag.set("mcp.tool.description", v.clone()); } + if let Some(v) = &tool.server_id { bag.set("mcp.tool.server_id", v.clone()); } + if let Some(v) = &tool.namespace { bag.set("mcp.tool.namespace", v.clone()); } + } + if let Some(res) = &mcp.resource { + bag.set("mcp.resource.uri", res.uri.clone()); + if let Some(v) = &res.name { bag.set("mcp.resource.name", v.clone()); } + if let Some(v) = &res.description { bag.set("mcp.resource.description", v.clone()); } + if let Some(v) = &res.mime_type { bag.set("mcp.resource.mime_type", v.clone()); } + if let Some(v) = &res.server_id { bag.set("mcp.resource.server_id", v.clone()); } + } + if let Some(prompt) = &mcp.prompt { + bag.set("mcp.prompt.name", prompt.name.clone()); + if let Some(v) = &prompt.description { bag.set("mcp.prompt.description", v.clone()); } + if let Some(v) = &prompt.server_id { bag.set("mcp.prompt.server_id", v.clone()); } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::mcp::{ResourceMetadata, ToolMetadata}; + + #[test] + fn tool_metadata_flattens() { + let mcp = MCPExtension { + tool: Some(ToolMetadata { + name: "get_compensation".into(), + description: Some("HR comp lookup".into()), + server_id: Some("hr-srv".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_mcp(&mcp, &mut bag); + assert_eq!(bag.get_string("mcp.tool.name"), Some("get_compensation")); + assert_eq!(bag.get_string("mcp.tool.description"), Some("HR comp lookup")); + assert_eq!(bag.get_string("mcp.tool.server_id"), Some("hr-srv")); + // Schemas are deliberately not in the bag. + assert!(!bag.contains("mcp.tool.input_schema")); + } + + #[test] + fn resource_uri_is_required_field() { + let mcp = MCPExtension { + resource: Some(ResourceMetadata { + uri: "hr://employees/123".into(), + mime_type: Some("application/json".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_mcp(&mcp, &mut bag); + assert_eq!(bag.get_string("mcp.resource.uri"), Some("hr://employees/123")); + assert_eq!(bag.get_string("mcp.resource.mime_type"), Some("application/json")); + } +} diff --git a/crates/apl-cmf/src/meta.rs b/crates/apl-cmf/src/meta.rs new file mode 100644 index 00000000..7f1ba17a --- /dev/null +++ b/crates/apl-cmf/src/meta.rs @@ -0,0 +1,57 @@ +// Location: ./crates/apl-cmf/src/meta.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// MetaExtension → AttributeBag. +// +// Namespace: +// meta.entity_type : String ("tool" | "resource" | "prompt" | "llm") +// meta.entity_name : String +// meta.tags : StringSet ← used by spec-level tag-driven policy inheritance +// meta.scope : String +// meta.properties. : String + +use apl_core::AttributeBag; +use cpex_core::extensions::MetaExtension; +use std::collections::HashSet; + +pub fn extract_meta(meta: &MetaExtension, bag: &mut AttributeBag) { + if let Some(v) = &meta.entity_type { bag.set("meta.entity_type", v.clone()); } + if let Some(v) = &meta.entity_name { bag.set("meta.entity_name", v.clone()); } + if !meta.tags.is_empty() { + let tags: HashSet = meta.tags.iter().cloned().collect(); + bag.set("meta.tags", tags); + } + if let Some(v) = &meta.scope { bag.set("meta.scope", v.clone()); } + for (k, v) in &meta.properties { + bag.set(format!("meta.properties.{}", k), v.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn tags_and_properties_flatten() { + let meta = MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + tags: HashSet::from(["pii".to_string(), "sensitive".to_string()]), + scope: Some("hr".into()), + properties: HashMap::from([ + ("owner".to_string(), "compliance".to_string()), + ]), + }; + let mut bag = AttributeBag::new(); + extract_meta(&meta, &mut bag); + assert_eq!(bag.get_string("meta.entity_type"), Some("tool")); + assert_eq!(bag.get_string("meta.entity_name"), Some("get_compensation")); + assert!(bag.set_contains("meta.tags", "pii")); + assert!(bag.set_contains("meta.tags", "sensitive")); + assert_eq!(bag.get_string("meta.scope"), Some("hr")); + assert_eq!(bag.get_string("meta.properties.owner"), Some("compliance")); + } +} diff --git a/crates/apl-cmf/src/payload.rs b/crates/apl-cmf/src/payload.rs new file mode 100644 index 00000000..11a22f7d --- /dev/null +++ b/crates/apl-cmf/src/payload.rs @@ -0,0 +1,150 @@ +// Location: ./crates/apl-cmf/src/payload.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// JSON args/result payload → AttributeBag. +// +// Leaf scalars at any nesting depth land in the bag under their dotted +// path, prefixed with `args.` or `result.`. Nested objects recurse; +// arrays-of-strings flatten into a StringSet; arrays of mixed/scalar +// types are skipped (no list scalar attribute in the bag). +// +// Examples: +// args = { "include_ssn": true, +// "user": { "id": "alice", "roles": ["hr", "manager"] } } +// → args.include_ssn : Bool(true) +// args.user.id : String("alice") +// args.user.roles : StringSet({"hr", "manager"}) +// +// Null values are skipped (consistent with bag's missing-key semantics). + +use apl_core::AttributeBag; +use serde_json::Value; +use std::collections::HashSet; + +use crate::constants::{BAG_ARGS_PREFIX, BAG_RESULT_PREFIX}; + +/// Flatten an args object into `args.*` keys. +pub fn extract_args(args: &Value, bag: &mut AttributeBag) { + // `walk` builds dotted paths itself; strip the trailing `.` from + // the canonical prefix to match its signature. + walk(args, BAG_ARGS_PREFIX.trim_end_matches('.'), bag); +} + +/// Flatten a result object into `result.*` keys. +pub fn extract_result(result: &Value, bag: &mut AttributeBag) { + walk(result, BAG_RESULT_PREFIX.trim_end_matches('.'), bag); +} + +pub(crate) fn walk(value: &Value, prefix: &str, bag: &mut AttributeBag) { + match value { + Value::Object(map) => { + for (key, sub) in map { + let dotted = if prefix.is_empty() { key.clone() } else { format!("{}.{}", prefix, key) }; + walk(sub, &dotted, bag); + } + } + Value::Array(items) => { + // Promote string-only arrays to StringSet — supports + // `args.tags contains "urgent"` predicates. + let mut all_strings: HashSet = HashSet::new(); + let mut ok = true; + for item in items { + if let Some(s) = item.as_str() { + all_strings.insert(s.to_string()); + } else { + ok = false; + break; + } + } + if ok && !all_strings.is_empty() { + bag.set(prefix, all_strings); + } + // Non-string arrays (mixed, numeric, nested): silently skipped + // — no list scalar in the bag for those. + } + Value::String(s) => bag.set(prefix, s.clone()), + Value::Bool(b) => bag.set(prefix, *b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + bag.set(prefix, i); + } else if let Some(f) = n.as_f64() { + bag.set(prefix, f); + } + } + Value::Null => {} // Skip — equivalent to "key not present." + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn args_scalars_at_top_level() { + let args = json!({ "include_ssn": true, "amount": 100, "name": "alice" }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert_eq!(bag.get_bool("args.include_ssn"), Some(true)); + assert_eq!(bag.get_int("args.amount"), Some(100)); + assert_eq!(bag.get_string("args.name"), Some("alice")); + } + + #[test] + fn args_nested_objects_dotted() { + let args = json!({ "user": { "id": "alice", "profile": { "tier": "gold" } } }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert_eq!(bag.get_string("args.user.id"), Some("alice")); + assert_eq!(bag.get_string("args.user.profile.tier"), Some("gold")); + } + + #[test] + fn args_string_array_becomes_string_set() { + let args = json!({ "tags": ["urgent", "audit"] }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert!(bag.set_contains("args.tags", "urgent")); + assert!(bag.set_contains("args.tags", "audit")); + assert!(!bag.set_contains("args.tags", "missing")); + } + + #[test] + fn args_mixed_array_is_skipped() { + let args = json!({ "mixed": ["a", 1, true] }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + // No `args.mixed` key — type didn't unify, so we dropped it. + assert!(!bag.contains("args.mixed")); + } + + #[test] + fn args_null_is_treated_as_missing() { + let args = json!({ "maybe": null, "yes": true }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert!(!bag.contains("args.maybe")); + assert_eq!(bag.get_bool("args.yes"), Some(true)); + } + + #[test] + fn result_uses_result_prefix() { + let result = json!({ "ssn": "123-45-6789", "salary": 50000 }); + let mut bag = AttributeBag::new(); + extract_result(&result, &mut bag); + assert_eq!(bag.get_string("result.ssn"), Some("123-45-6789")); + assert_eq!(bag.get_int("result.salary"), Some(50000)); + // No args.* keys collected. + assert!(!bag.contains("args.ssn")); + } + + #[test] + fn float_numbers_land_as_float() { + let args = json!({ "score": 0.92 }); + let mut bag = AttributeBag::new(); + extract_args(&args, &mut bag); + assert_eq!(bag.get_float("args.score"), Some(0.92)); + } +} diff --git a/crates/apl-cmf/src/provenance.rs b/crates/apl-cmf/src/provenance.rs new file mode 100644 index 00000000..27ddba07 --- /dev/null +++ b/crates/apl-cmf/src/provenance.rs @@ -0,0 +1,38 @@ +// Location: ./crates/apl-cmf/src/provenance.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// ProvenanceExtension → AttributeBag. +// +// Namespace: +// provenance.source : String +// provenance.message_id : String +// provenance.parent_id : String + +use apl_core::AttributeBag; +use cpex_core::extensions::ProvenanceExtension; + +pub fn extract_provenance(p: &ProvenanceExtension, bag: &mut AttributeBag) { + if let Some(v) = &p.source { bag.set("provenance.source", v.clone()); } + if let Some(v) = &p.message_id { bag.set("provenance.message_id", v.clone()); } + if let Some(v) = &p.parent_id { bag.set("provenance.parent_id", v.clone()); } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_threading_fields() { + let p = ProvenanceExtension { + source: Some("upstream-mcp".into()), + message_id: Some("msg-1".into()), + parent_id: Some("msg-0".into()), + }; + let mut bag = AttributeBag::new(); + extract_provenance(&p, &mut bag); + assert_eq!(bag.get_string("provenance.source"), Some("upstream-mcp")); + assert_eq!(bag.get_string("provenance.parent_id"), Some("msg-0")); + } +} diff --git a/crates/apl-cmf/src/request.rs b/crates/apl-cmf/src/request.rs new file mode 100644 index 00000000..7801b71f --- /dev/null +++ b/crates/apl-cmf/src/request.rs @@ -0,0 +1,54 @@ +// Location: ./crates/apl-cmf/src/request.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// RequestExtension → AttributeBag. +// +// Namespace: +// request.environment : String ("production" | "staging" | ...) +// request.request_id : String +// request.timestamp : String (ISO 8601 — bag stays scalar; predicates +// comparing timestamps would need plugins) +// request.trace_id : String +// request.span_id : String + +use apl_core::AttributeBag; +use cpex_core::extensions::RequestExtension; + +pub fn extract_request(req: &RequestExtension, bag: &mut AttributeBag) { + if let Some(v) = &req.environment { bag.set("request.environment", v.clone()); } + if let Some(v) = &req.request_id { bag.set("request.request_id", v.clone()); } + if let Some(v) = &req.timestamp { bag.set("request.timestamp", v.clone()); } + if let Some(v) = &req.trace_id { bag.set("request.trace_id", v.clone()); } + if let Some(v) = &req.span_id { bag.set("request.span_id", v.clone()); } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_all_present_fields() { + let req = RequestExtension { + environment: Some("production".into()), + request_id: Some("req-abc".into()), + timestamp: Some("2026-05-14T12:00:00Z".into()), + trace_id: Some("trace-1".into()), + span_id: Some("span-2".into()), + }; + let mut bag = AttributeBag::new(); + extract_request(&req, &mut bag); + assert_eq!(bag.get_string("request.environment"), Some("production")); + assert_eq!(bag.get_string("request.request_id"), Some("req-abc")); + assert_eq!(bag.get_string("request.trace_id"), Some("trace-1")); + } + + #[test] + fn missing_fields_skipped() { + let req = RequestExtension::default(); + let mut bag = AttributeBag::new(); + extract_request(&req, &mut bag); + assert!(bag.is_empty()); + } +} diff --git a/crates/apl-cmf/src/security.rs b/crates/apl-cmf/src/security.rs new file mode 100644 index 00000000..f23f9381 --- /dev/null +++ b/crates/apl-cmf/src/security.rs @@ -0,0 +1,525 @@ +// Location: ./crates/apl-cmf/src/security.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// SecurityExtension → AttributeBag. +// +// Namespace map (canonical — extend this comment when adding a new key): +// +// ----- Subject (user identity) ------------------------------------------ +// sec.subject.id → subject.id : String +// sec.subject.subject_type → subject.type : String +// sec.subject.roles → role. : Bool(true) +// sec.subject.permissions → perm.

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

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

= true` keys so policies can write +/// `require(client.role.partner)` the same way as `role.hr`. Claims +/// are flattened through the same JSON walker as `custom.*`, so +/// nested objects produce dotted-path keys. +pub fn extract_client(client: &ClientExtension, bag: &mut AttributeBag) { + bag.set("client.client_id", client.client_id.clone()); + if let Some(n) = &client.client_name { + bag.set("client.client_name", n.clone()); + } + bag.set("client.trust_level", trust_level_str(&client.trust_level)); + for role in &client.roles { + bag.set(format!("client.role.{}", role), true); + } + for perm in &client.permissions { + bag.set(format!("client.perm.{}", perm), true); + } + if !client.authorized_scopes.is_empty() { + let scopes: HashSet = client.authorized_scopes.iter().cloned().collect(); + bag.set("client.authorized_scopes", scopes); + } + if !client.authorized_audiences.is_empty() { + let auds: HashSet = client.authorized_audiences.iter().cloned().collect(); + bag.set("client.authorized_audiences", auds); + } + if !client.teams.is_empty() { + let teams: HashSet = client.teams.iter().cloned().collect(); + bag.set("client.teams", teams); + } + for (k, v) in &client.claims { + // Nested JSON claims flatten through the same walker `custom.*` + // uses — keeps semantics consistent across bridges. + crate::payload::walk(v, &format!("client.claim.{}", k), bag); + } +} + +/// Flatten a `WorkloadIdentity` into the bag under the given namespace +/// prefix — typically `"caller_workload"` or `"this_workload"`. Two +/// instances of this struct can coexist in `SecurityExtension` +/// (one inbound, one outbound) and they share the bag shape; the only +/// thing that varies is the namespace. +pub fn extract_workload(prefix: &str, w: &WorkloadIdentity, bag: &mut AttributeBag) { + if let Some(s) = &w.spiffe_id { + bag.set(format!("{}.spiffe_id", prefix), s.clone()); + } + if let Some(t) = &w.trust_domain { + bag.set(format!("{}.trust_domain", prefix), t.clone()); + } + if let Some(a) = &w.attestor { + bag.set(format!("{}.attestor", prefix), a.clone()); + } + if !w.selectors.is_empty() { + let selectors: HashSet = w.selectors.iter().cloned().collect(); + bag.set(format!("{}.selectors", prefix), selectors); + } + if let Some(id) = &w.client_id { + bag.set(format!("{}.client_id", prefix), id.clone()); + } + // `attested_at` intentionally omitted from the bag at v0 — APL + // doesn't carry DateTime as a bag value type, and policies that + // need it can opt into reading the typed extension directly. + let _ = &w.attested_at; +} + +/// Render the `ClientTrustLevel` enum as the bag string. Matches +/// `serde(rename_all = "snake_case")` on the type, with `Custom(s)` +/// rendering as `s` verbatim so policies can write +/// `client.trust_level == "partner-tier-A"`. The `_` arm exists +/// because `ClientTrustLevel` is `#[non_exhaustive]`; if a new +/// well-known variant lands upstream, this falls through to +/// "unknown" until we explicitly add a case — fail-loud rather than +/// silently picking one of the existing strings. +fn trust_level_str(level: &ClientTrustLevel) -> String { + match level { + ClientTrustLevel::FirstParty => "first_party".to_string(), + ClientTrustLevel::ThirdParty => "third_party".to_string(), + ClientTrustLevel::Internal => "internal".to_string(), + ClientTrustLevel::Custom(s) => s.clone(), + _ => "unknown".to_string(), + } +} + +fn subject_type_str(t: SubjectType) -> &'static str { + match t { + SubjectType::User => "user", + SubjectType::Agent => "agent", + SubjectType::Service => "service", + SubjectType::System => "system", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::{SubjectExtension, WorkloadIdentity}; + use std::collections::HashMap; + + fn alice() -> SecurityExtension { + SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice@corp.com".into()), + subject_type: Some(SubjectType::User), + roles: HashSet::from(["hr".to_string(), "manager".to_string()]), + permissions: HashSet::from(["view_ssn".to_string()]), + teams: HashSet::from(["compliance".to_string()]), + claims: HashMap::from([("iss".to_string(), "auth.corp".to_string())]), + }), + this_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/hr-tool".into()), + trust_domain: Some("corp.com".into()), + attestor: Some("spire-agent".into()), + selectors: vec!["k8s:ns:hr".into()], + client_id: Some("hr-tool".into()), + ..Default::default() + }), + auth_method: Some("jwt".into()), + ..Default::default() + } + } + + #[test] + fn subject_id_and_authenticated_marker() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_string("subject.id"), Some("alice@corp.com")); + assert_eq!(bag.get_bool("authenticated"), Some(true)); + assert_eq!(bag.get_string("subject.type"), Some("user")); + } + + #[test] + fn roles_become_individual_true_keys() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + // Each role → role. = true. DSL: `require(role.hr)`. + assert_eq!(bag.get_bool("role.hr"), Some(true)); + assert_eq!(bag.get_bool("role.manager"), Some(true)); + // A role Alice doesn't have is absent (not false — missing). + assert_eq!(bag.get_bool("role.finance"), None); + } + + #[test] + fn permissions_become_individual_true_keys() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_bool("perm.view_ssn"), Some(true)); + assert_eq!(bag.get_bool("perm.delete_user"), None); + } + + #[test] + fn teams_become_string_set() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert!(bag.set_contains("subject.teams", "compliance")); + assert!(!bag.set_contains("subject.teams", "engineering")); + } + + #[test] + fn claims_become_dotted_strings() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_string("claim.iss"), Some("auth.corp")); + } + + #[test] + fn this_workload_identity_keys() { + // `this_workload.*` namespace — our own attested identity. + // Distinct from the `agent.*` namespace of `AgentExtension` + // (session context) and the future `caller_workload.*` + // namespace for the inbound caller's SPIFFE identity. + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_string("this_workload.client_id"), Some("hr-tool")); + assert_eq!( + bag.get_string("this_workload.spiffe_id"), + Some("spiffe://corp.com/hr-tool") + ); + assert_eq!(bag.get_string("this_workload.trust_domain"), Some("corp.com")); + assert_eq!(bag.get_string("this_workload.attestor"), Some("spire-agent")); + assert!(bag.set_contains("this_workload.selectors", "k8s:ns:hr")); + } + + #[test] + fn auth_method_is_top_level() { + let mut bag = AttributeBag::new(); + extract_security(&alice(), &mut bag); + assert_eq!(bag.get_string("auth_method"), Some("jwt")); + } + + #[test] + fn labels_and_classification() { + let mut sec = SecurityExtension::default(); + sec.add_label("PII"); + sec.add_label("financial"); + sec.classification = Some("confidential".into()); + + let mut bag = AttributeBag::new(); + extract_security(&sec, &mut bag); + assert!(bag.set_contains("security.labels", "PII")); + assert!(bag.set_contains("security.labels", "financial")); + assert_eq!(bag.get_string("security.classification"), Some("confidential")); + } + + #[test] + fn no_subject_means_no_authenticated_marker() { + let sec = SecurityExtension::default(); // subject: None + let mut bag = AttributeBag::new(); + extract_security(&sec, &mut bag); + assert!(!bag.contains("authenticated")); + assert!(!bag.contains("subject.id")); + } + + #[test] + fn subject_without_id_is_not_authenticated() { + // A subject record exists but has no id — represents a recognized + // but unauthenticated principal (e.g. anonymous). The marker must + // not be set. + let sec = SecurityExtension { + subject: Some(SubjectExtension { + id: None, + roles: HashSet::from(["guest".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_security(&sec, &mut bag); + assert!(!bag.contains("authenticated")); + // But role keys still land — role.guest is true. + assert_eq!(bag.get_bool("role.guest"), Some(true)); + } + + // ----------------------------------------------------------------- + // Client (OAuth application identity) bag namespace + // ----------------------------------------------------------------- + + fn agent_client() -> ClientExtension { + ClientExtension { + client_id: "agent-app".into(), + client_name: Some("Agent App".into()), + trust_level: ClientTrustLevel::FirstParty, + authorized_scopes: vec!["read".into(), "write".into()], + authorized_audiences: vec!["https://api.example.com".into()], + roles: vec!["partner".into()], + permissions: vec!["call_tool".into()], + teams: vec!["acme".into()], + claims: HashMap::from([ + ("iss".to_string(), serde_json::json!("auth.example.com")), + ( + "scope_meta".to_string(), + serde_json::json!({ "max_calls_per_min": 60 }), + ), + ]), + } + } + + #[test] + fn client_required_id_and_trust_level() { + let mut bag = AttributeBag::new(); + extract_client(&agent_client(), &mut bag); + assert_eq!(bag.get_string("client.client_id"), Some("agent-app")); + assert_eq!(bag.get_string("client.client_name"), Some("Agent App")); + assert_eq!(bag.get_string("client.trust_level"), Some("first_party")); + } + + #[test] + fn client_roles_and_perms_become_individual_true_keys() { + // Symmetric with the subject pattern: `client.role.partner = true`. + // Lets policies write `require(client.role.partner)`. + let mut bag = AttributeBag::new(); + extract_client(&agent_client(), &mut bag); + assert_eq!(bag.get_bool("client.role.partner"), Some(true)); + assert_eq!(bag.get_bool("client.perm.call_tool"), Some(true)); + assert_eq!(bag.get_bool("client.role.nonexistent"), None); + } + + #[test] + fn client_scopes_audiences_teams_are_string_sets() { + let mut bag = AttributeBag::new(); + extract_client(&agent_client(), &mut bag); + assert!(bag.set_contains("client.authorized_scopes", "read")); + assert!(bag.set_contains("client.authorized_scopes", "write")); + assert!(bag.set_contains( + "client.authorized_audiences", + "https://api.example.com", + )); + assert!(bag.set_contains("client.teams", "acme")); + } + + #[test] + fn client_claims_flatten_nested_paths() { + // Claims are `HashMap` — nested objects must + // flatten through the same walker `custom.*` uses. Asserts the + // JSON-walker integration works for client just like custom. + let mut bag = AttributeBag::new(); + extract_client(&agent_client(), &mut bag); + assert_eq!(bag.get_string("client.claim.iss"), Some("auth.example.com")); + assert_eq!( + bag.get_int("client.claim.scope_meta.max_calls_per_min"), + Some(60), + ); + } + + #[test] + fn trust_level_custom_renders_verbatim() { + let mut client = agent_client(); + client.trust_level = ClientTrustLevel::Custom("partner-tier-A".into()); + let mut bag = AttributeBag::new(); + extract_client(&client, &mut bag); + assert_eq!(bag.get_string("client.trust_level"), Some("partner-tier-A")); + } + + // ----------------------------------------------------------------- + // Workload (extract_workload helper — both prefixes) + // ----------------------------------------------------------------- + + fn workload_fixture() -> WorkloadIdentity { + WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/svc/foo".into()), + trust_domain: Some("corp.com".into()), + attestor: Some("spire-agent".into()), + selectors: vec!["k8s:ns:foo".into(), "k8s:sa:foo-sa".into()], + client_id: Some("foo-svc".into()), + ..Default::default() + } + } + + #[test] + fn extract_workload_populates_under_caller_prefix() { + // The same WorkloadIdentity feeds two distinct bag namespaces + // depending on which slot it lives in. This test pins + // `caller_workload.*`; the next pins `this_workload.*`. + let mut bag = AttributeBag::new(); + extract_workload("caller_workload", &workload_fixture(), &mut bag); + assert_eq!( + bag.get_string("caller_workload.spiffe_id"), + Some("spiffe://corp.com/svc/foo"), + ); + assert_eq!( + bag.get_string("caller_workload.trust_domain"), + Some("corp.com"), + ); + assert!(bag.set_contains("caller_workload.selectors", "k8s:ns:foo")); + // And the `this_workload.*` namespace must stay empty in this + // case — caller-prefix call must not leak into the other slot. + assert_eq!(bag.get_string("this_workload.spiffe_id"), None); + } + + #[test] + fn extract_workload_populates_under_this_prefix() { + let mut bag = AttributeBag::new(); + extract_workload("this_workload", &workload_fixture(), &mut bag); + assert_eq!( + bag.get_string("this_workload.spiffe_id"), + Some("spiffe://corp.com/svc/foo"), + ); + assert_eq!(bag.get_string("this_workload.attestor"), Some("spire-agent")); + assert_eq!(bag.get_string("caller_workload.spiffe_id"), None); + } + + // ----------------------------------------------------------------- + // extract_security orchestrates all four identity slots + // ----------------------------------------------------------------- + + #[test] + fn extract_security_populates_all_four_identity_namespaces() { + // Single fixture exercising subject + client + caller_workload + + // this_workload. Documents that one SecurityExtension can carry + // all four principals on a single request and the bridge fans + // them out into disjoint namespaces. + let sec = SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }), + client: Some(agent_client()), + caller_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/inbound".into()), + ..Default::default() + }), + this_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/gateway".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut bag = AttributeBag::new(); + extract_security(&sec, &mut bag); + assert_eq!(bag.get_string("subject.id"), Some("alice")); + assert_eq!(bag.get_string("client.client_id"), Some("agent-app")); + assert_eq!( + bag.get_string("caller_workload.spiffe_id"), + Some("spiffe://corp.com/inbound"), + ); + assert_eq!( + bag.get_string("this_workload.spiffe_id"), + Some("spiffe://corp.com/gateway"), + ); + } +} diff --git a/crates/apl-cmf/tests/end_to_end.rs b/crates/apl-cmf/tests/end_to_end.rs new file mode 100644 index 00000000..cdfa1a11 --- /dev/null +++ b/crates/apl-cmf/tests/end_to_end.rs @@ -0,0 +1,275 @@ +// Location: ./crates/apl-cmf/tests/end_to_end.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Full vertical slice: cpex-core extensions → apl-cmf bridge → apl-core +// evaluator on a YAML-compiled route. If this test breaks, the whole +// stack is misaligned (extension shape, bag vocabulary, or compiler). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use apl_cmf::BagBuilder; +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, + NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, + PluginInvocation, PluginInvoker, PluginOutcome, RoutePayload, +}; +use async_trait::async_trait; +use cpex_core::extensions::{ + DelegationExtension, DelegationHop, SecurityExtension, SubjectExtension, SubjectType, + WorkloadIdentity, +}; +use serde_json::json; + +// `evaluate_route` takes `&Arc` / `&Arc` +// so the call paths inside apl-core can `Arc::clone` an owned, 'static reference +// into each spawned branch (E3.2). All tests pass the same no-op stubs; wrap once. +fn pdp() -> Arc { + Arc::new(AllowPdp) +} +fn plugins() -> Arc { + Arc::new(NoPlugins) +} +fn delegations() -> Arc { + Arc::new(NoopDelegationInvoker) +} + +// HR route from unified-config-proposal.md §Example 1. +const HR_ROUTE_YAML: &str = r#" +routes: + get_employee: + args: + employee_id: "str" + policy: + - "require(authenticated)" + - "delegation.depth > 2: deny" + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + employee_id: "str | mask(4)" +"#; + +// ---------- PDP / Plugin stubs ---------- + +struct AllowPdp; +#[async_trait] +impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { decision: Decision::Allow, diagnostics: vec![] }) + } +} + +struct NoPlugins; +#[async_trait] +impl PluginInvoker for NoPlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + Err(PluginError::NotFound(name.into())) + } +} + +// ---------- Realistic extension fixtures ---------- + +fn alice_hr() -> SecurityExtension { + SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice@corp.com".into()), + subject_type: Some(SubjectType::User), + roles: HashSet::from(["hr".to_string()]), + permissions: HashSet::from(["view_ssn".to_string()]), + teams: HashSet::from(["compliance".to_string()]), + claims: HashMap::from([("iss".to_string(), "auth.corp".to_string())]), + }), + this_workload: Some(WorkloadIdentity { + client_id: Some("hr-tool".into()), + ..Default::default() + }), + auth_method: Some("jwt".into()), + ..Default::default() + } +} + +fn mallory_no_perm() -> SecurityExtension { + SecurityExtension { + subject: Some(SubjectExtension { + id: Some("mallory@corp.com".into()), + subject_type: Some(SubjectType::User), + ..Default::default() + }), + auth_method: Some("jwt".into()), + ..Default::default() + } +} + +fn shallow_delegation() -> DelegationExtension { + let mut del = DelegationExtension { + origin_subject_id: Some("alice@corp.com".into()), + ..Default::default() + }; + del.append_hop(DelegationHop { + subject_id: "alice@corp.com".into(), + ..Default::default() + }); + del +} + +fn deep_delegation() -> DelegationExtension { + let mut del = DelegationExtension::default(); + for hop in ["a", "b", "c"] { + del.append_hop(DelegationHop { + subject_id: hop.into(), + ..Default::default() + }); + } + del +} + +// ---------- Tests ---------- + +#[tokio::test] +async fn alice_full_route_through_cmf_bridge() { + let mut bag = BagBuilder::new() + .with_security(&alice_hr()) + .with_delegation(&shallow_delegation()) + .with_route_key("get_employee") + .build(); + + // Sanity-check the bag came out the way we expect. + assert_eq!(bag.get_bool("authenticated"), Some(true)); + assert_eq!(bag.get_bool("role.hr"), Some(true)); + assert_eq!(bag.get_bool("perm.view_ssn"), Some(true)); + assert_eq!(bag.get_int("delegation.depth"), Some(1)); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ + "ssn": "555-12-3456", + "salary": 95000, + "employee_id": "123-45-6789", + }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + let result = payload.result.as_ref().unwrap(); + // view_ssn=true and role.hr=true → both fields kept; employee_id masked. + assert_eq!(result["ssn"], json!("555-12-3456")); + assert_eq!(result["salary"], json!(95000)); + assert_eq!(result["employee_id"], json!("*******6789")); +} + +#[tokio::test] +async fn mallory_gets_both_fields_redacted_through_cmf_bridge() { + let mut bag = BagBuilder::new() + .with_security(&mallory_no_perm()) + .with_delegation(&shallow_delegation()) + .build(); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "555-44-3333" }), + json!({ + "ssn": "111-22-3333", + "salary": 80000, + "employee_id": "555-44-3333", + }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + let result = payload.result.as_ref().unwrap(); + // Neither role.hr nor perm.view_ssn populated → both redact()s fire. + assert_eq!(result["ssn"], json!("[REDACTED]")); + assert_eq!(result["salary"], json!("[REDACTED]")); + assert_eq!(result["employee_id"], json!("*******3333")); +} + +#[tokio::test] +async fn deep_delegation_denies_through_cmf_bridge() { + let mut bag = BagBuilder::new() + .with_security(&alice_hr()) + .with_delegation(&deep_delegation()) // depth = 3 + .build(); + + assert_eq!(bag.get_int("delegation.depth"), Some(3)); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), + ); + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert!(matches!(r.decision, Decision::Deny { .. })); + // Result fields untouched — the result phase never ran. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("x")); +} + +#[tokio::test] +async fn args_attributes_flow_into_bag_for_policy_use() { + // Bridge args payload into the bag, then check that a policy + // predicate using `args.` evaluates against it. Uses an + // ad-hoc route, since the canonical HR route doesn't reference + // `args.*` in its policy block. + let yaml = r#" +routes: + guarded_route: + policy: + - "args.include_ssn == true: deny" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("guarded_route").unwrap(); + + let args = json!({ "include_ssn": true, "id": "abc" }); + let mut bag = BagBuilder::new() + .with_security(&alice_hr()) + .with_args(&args) + .build(); + assert_eq!(bag.get_bool("args.include_ssn"), Some(true)); + + let mut payload = RoutePayload::new(args); + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy"), "got source {}", rule_source); + } + d => panic!("expected Deny on include_ssn, got {:?}", d), + } +} + +#[tokio::test] +async fn anonymous_user_denied_at_authenticated_check() { + // No security extension at all → no `authenticated` key in bag → + // `require(authenticated)` denies. + let mut bag = BagBuilder::new() + .with_delegation(&shallow_delegation()) + .build(); + assert!(!bag.contains("authenticated")); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), + ); + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert!(matches!(r.decision, Decision::Deny { .. })); +} diff --git a/crates/apl-core/Cargo.toml b/crates/apl-core/Cargo.toml new file mode 100644 index 00000000..b04b0951 --- /dev/null +++ b/crates/apl-core/Cargo.toml @@ -0,0 +1,32 @@ +# Location: ./crates/apl-core/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# APL core — predicate language, compiler, evaluator. +# Module structure follows docs/specs/apl-design.md §4. + +[package] +name = "apl-core" +description = "APL — Attribute Policy Language core (compiler + evaluator)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +# Plain rlib; APL is consumed by other workspace crates (apl-cmf, apl-cpex) +# and does not need cdylib for FFI. + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +regex = { workspace = true } +futures = { workspace = true } +cpex-orchestration = { path = "../cpex-orchestration" } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/apl-core/src/attributes.rs b/crates/apl-core/src/attributes.rs new file mode 100644 index 00000000..17bac0e0 --- /dev/null +++ b/crates/apl-core/src/attributes.rs @@ -0,0 +1,215 @@ +// Location: ./crates/apl-core/src/attributes.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// AttributeBag — flat namespace for policy evaluation. +// +// The DSL evaluates predicates against a flat bag of named, typed values. +// Each attribute source (cpex-core extensions, route args, session context, +// custom plugin namespaces) drops keys into the bag through the +// `AttributeExtractor` trait. +// +// A flat bag (rather than nested object access) means the evaluator never +// has to know which extension a key came from — it just queries by name. +// New attribute sources are additive: implement `AttributeExtractor` for +// them and the evaluator picks them up unchanged. +// +// Mapping from cpex-core extensions into the bag lives in `apl-cmf`, not +// here. See docs/specs/apl-design.md §4 for the module layering. + +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +/// A single attribute value the evaluator can compare against. +/// +/// The five variants cover every shape the DSL needs: +/// `Bool` for `authenticated` / `role.*` / `perm.*`, +/// `Int` for counts and depths, +/// `Float` for confidences and ages, +/// `String` for identifiers, +/// `StringSet` for set-membership operators (`contains`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AttributeValue { + Bool(bool), + Int(i64), + Float(f64), + String(String), + StringSet(HashSet), +} + +impl From for AttributeValue { + fn from(v: bool) -> Self { AttributeValue::Bool(v) } +} +impl From for AttributeValue { + fn from(v: i64) -> Self { AttributeValue::Int(v) } +} +impl From for AttributeValue { + fn from(v: f64) -> Self { AttributeValue::Float(v) } +} +impl From<&str> for AttributeValue { + fn from(v: &str) -> Self { AttributeValue::String(v.to_string()) } +} +impl From for AttributeValue { + fn from(v: String) -> Self { AttributeValue::String(v) } +} +impl From> for AttributeValue { + fn from(v: HashSet) -> Self { AttributeValue::StringSet(v) } +} + +/// Flat key→value namespace consumed by the evaluator. +/// +/// Populate via `set()` and/or `AttributeExtractor::extract()`; query via +/// the typed `get_*` methods. Once handed to the evaluator the bag is +/// read-only by convention (not enforced — `&mut` borrows are how you +/// build it up in the first place). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AttributeBag { + attrs: HashMap, +} + +impl AttributeBag { + pub fn new() -> Self { + Self { attrs: HashMap::new() } + } + + pub fn set(&mut self, key: impl Into, value: impl Into) { + self.attrs.insert(key.into(), value.into()); + } + + pub fn get(&self, key: &str) -> Option<&AttributeValue> { + self.attrs.get(key) + } + + pub fn contains(&self, key: &str) -> bool { + self.attrs.contains_key(key) + } + + pub fn get_bool(&self, key: &str) -> Option { + match self.get(key) { + Some(AttributeValue::Bool(v)) => Some(*v), + _ => None, + } + } + + pub fn get_int(&self, key: &str) -> Option { + match self.get(key) { + Some(AttributeValue::Int(v)) => Some(*v), + _ => None, + } + } + + pub fn get_float(&self, key: &str) -> Option { + match self.get(key) { + Some(AttributeValue::Float(v)) => Some(*v), + // Promote int → float so `depth > 2.5`-style predicates work + // when depth is stored as Int. + Some(AttributeValue::Int(v)) => Some(*v as f64), + _ => None, + } + } + + pub fn get_string(&self, key: &str) -> Option<&str> { + match self.get(key) { + Some(AttributeValue::String(v)) => Some(v.as_str()), + _ => None, + } + } + + pub fn get_string_set(&self, key: &str) -> Option<&HashSet> { + match self.get(key) { + Some(AttributeValue::StringSet(v)) => Some(v), + _ => None, + } + } + + /// DSL ` contains ` — false if the key is missing or not a set. + pub fn set_contains(&self, key: &str, value: &str) -> bool { + self.get_string_set(key) + .map(|set| set.contains(value)) + .unwrap_or(false) + } + + pub fn len(&self) -> usize { + self.attrs.len() + } + + pub fn is_empty(&self) -> bool { + self.attrs.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.attrs.iter().map(|(k, v)| (k.as_str(), v)) + } +} + +/// Source of attributes. Implementors drop keys into the bag under a +/// consistent namespace prefix: +/// +/// - cpex-core `SecurityExtension.subject` → `subject.*`, `role.*`, `perm.*` +/// - cpex-core `SecurityExtension.client` → `client.*` +/// - cpex-core `DelegationExtension` → `delegation.*`, `delegated` +/// - Route args → `args.*` +/// - Session context → `session.*` +/// +/// Implementations for the cpex-core extensions live in `apl-cmf`, not here. +pub trait AttributeExtractor { + fn extract(&self, bag: &mut AttributeBag); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_bag() { + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 2i64); + bag.set("subject.id", "alice@corp.com"); + bag.set("intent.confidence", 0.92f64); + + assert_eq!(bag.get_bool("authenticated"), Some(true)); + assert_eq!(bag.get_int("delegation.depth"), Some(2)); + assert_eq!(bag.get_string("subject.id"), Some("alice@corp.com")); + assert_eq!(bag.get_float("intent.confidence"), Some(0.92)); + } + + #[test] + fn int_to_float_promotion() { + let mut bag = AttributeBag::new(); + bag.set("delegation.depth", 2i64); + assert_eq!(bag.get_float("delegation.depth"), Some(2.0)); + } + + #[test] + fn string_set_contains() { + let mut bag = AttributeBag::new(); + bag.set( + "session.labels", + HashSet::from(["PII".to_string(), "financial".to_string()]), + ); + + assert!(bag.set_contains("session.labels", "PII")); + assert!(bag.set_contains("session.labels", "financial")); + assert!(!bag.set_contains("session.labels", "PHI")); + } + + #[test] + fn missing_keys() { + let bag = AttributeBag::new(); + assert_eq!(bag.get_bool("nonexistent"), None); + assert_eq!(bag.get_int("nonexistent"), None); + assert!(!bag.set_contains("nonexistent", "value")); + } + + #[test] + fn type_mismatch_returns_none() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + // Stored as String; asking for Bool returns None, not a coerced value. + assert_eq!(bag.get_bool("subject.id"), None); + assert_eq!(bag.get_int("subject.id"), None); + } +} diff --git a/crates/apl-core/src/evaluator.rs b/crates/apl-core/src/evaluator.rs new file mode 100644 index 00000000..aa5f80d5 --- /dev/null +++ b/crates/apl-core/src/evaluator.rs @@ -0,0 +1,2563 @@ +// Location: ./crates/apl-core/src/evaluator.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL evaluator — walks the IR against an AttributeBag and returns a Decision. +// +// The evaluator is sync and infallible by design. Missing attributes resolve +// to `false` (DSL spec §2.6); operator type mismatches resolve to `false`. +// The host drives the four phases separately by calling `evaluate_rules` once +// per declared phase — phase orchestration lives in `apl-cpex`. +// +// Semantics anchored in: +// - DSL spec apl-dsl-spec.md §2 (operators), §3 (actions), §8.1 (require) +// - apl-design.md §7 (native fast-path, sync inside async outer) + +use std::sync::Arc; + +use crate::attributes::{AttributeBag, AttributeValue}; +use crate::pipeline::{Pipeline, ScanKind, Stage, TaintEvent, TaintScope, TypeCheck}; +use crate::rules::{CompareOp, Condition, Effect, Expression, Literal, Rule}; +use crate::step::{PdpResolver, PluginInvocation, PluginInvoker}; + +/// Outcome of evaluating a phase's rule list. +#[derive(Debug, Clone, PartialEq)] +pub enum Decision { + /// No `deny` rule fired. Pipeline proceeds. + Allow, + /// A `deny` rule fired. Pipeline halts. + Deny { + reason: Option, + /// `Rule.source` of the rule that produced the deny — for audit logs. + rule_source: String, + }, +} + +/// Evaluate a phase's rules against the bag. +/// +/// Spec §3 semantics: +/// - First `deny` halts; subsequent rules / effects don't run. +/// - `allow` effects *do not* short-circuit — evaluation continues to +/// the next effect (then to the next rule). +/// - If no rule denies, the phase resolves to `Decision::Allow`. +/// +/// Sync fast path — only handles control effects (`Allow` / `Deny`). +/// Rules containing `Plugin` / `Delegate` / `Taint` effects must go +/// through [`evaluate_steps`] instead, which has the async invoker +/// traits wired up. This function silently skips non-control effects +/// so a rule list mixed with `Plugin` still terminates cleanly on a +/// later `Deny` — but the side effects don't fire. Caller's job to +/// pick the right entry point for the effects in the rules. +pub fn evaluate_rules(rules: &[Rule], bag: &AttributeBag) -> Decision { + for rule in rules { + if !eval_expression(&rule.condition, bag) { + continue; + } + for effect in &rule.effects { + match effect { + Effect::Allow => continue, + Effect::Deny { reason, code } => { + // `code` override on the effect takes precedence + // over the auto-generated rule source position, + // so author-stable categories survive YAML edits. + let rule_source = code + .clone() + .unwrap_or_else(|| rule.source.clone()); + return Decision::Deny { + reason: reason.clone(), + rule_source, + }; + } + // Plugin / Delegate / Taint require the async step + // path; ignore here. See doc comment above. + _ => continue, + } + } + } + Decision::Allow +} + +fn eval_expression(expr: &Expression, bag: &AttributeBag) -> bool { + match expr { + Expression::Condition(c) => eval_condition(c, bag), + Expression::And(parts) => parts.iter().all(|e| eval_expression(e, bag)), + Expression::Or(parts) => parts.iter().any(|e| eval_expression(e, bag)), + Expression::Not(inner) => !eval_expression(inner, bag), + Expression::Always => true, + } +} + +fn eval_condition(cond: &Condition, bag: &AttributeBag) -> bool { + match cond { + Condition::IsTrue { key } => bag.get_bool(key).unwrap_or(false), + Condition::IsFalse { key } => !bag.get_bool(key).unwrap_or(false), + Condition::Exists { key } => bag.contains(key), + Condition::Comparison { key, op, value } => eval_comparison(key, *op, value, bag), + Condition::InSet { value_key, set_key, negate } => { + let in_set = match (bag.get_string(value_key), bag.get_string_set(set_key)) { + (Some(s), Some(set)) => set.contains(s), + _ => false, // missing key or wrong type → not in set + }; + if *negate { !in_set } else { in_set } + } + } +} + +fn eval_comparison(key: &str, op: CompareOp, lit: &Literal, bag: &AttributeBag) -> bool { + let attr = match bag.get(key) { + Some(v) => v, + None => return false, // missing → false (spec §2.6) + }; + + match op { + CompareOp::Contains => match (attr, lit) { + (AttributeValue::StringSet(_), Literal::String(s)) => bag.set_contains(key, s), + _ => false, + }, + CompareOp::Eq => values_eq(attr, lit), + CompareOp::NotEq => !values_eq(attr, lit), + CompareOp::Gt | CompareOp::GtEq | CompareOp::Lt | CompareOp::LtEq => { + numeric_compare(attr, lit, op) + } + } +} + +fn values_eq(attr: &AttributeValue, lit: &Literal) -> bool { + match (attr, lit) { + (AttributeValue::Bool(a), Literal::Bool(b)) => a == b, + (AttributeValue::Int(a), Literal::Int(b)) => a == b, + (AttributeValue::Float(a), Literal::Float(b)) => a == b, + (AttributeValue::String(a), Literal::String(b)) => a == b, + // Int↔Float promotion for equality (matches AttributeBag::get_float). + (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64) == *b, + (AttributeValue::Float(a), Literal::Int(b)) => *a == (*b as f64), + _ => false, + } +} + +fn numeric_compare(attr: &AttributeValue, lit: &Literal, op: CompareOp) -> bool { + let (a, b) = match (attr, lit) { + (AttributeValue::Int(a), Literal::Int(b)) => (*a as f64, *b as f64), + (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64, *b), + (AttributeValue::Float(a), Literal::Int(b)) => (*a, *b as f64), + (AttributeValue::Float(a), Literal::Float(b)) => (*a, *b), + // Non-numeric operands: order operators don't apply → false (spec §2.3). + _ => return false, + }; + match op { + CompareOp::Gt => a > b, + CompareOp::GtEq => a >= b, + CompareOp::Lt => a < b, + CompareOp::LtEq => a <= b, + _ => unreachable!("numeric_compare called with non-numeric op"), + } +} + +// ===================================================================== +// Async effect evaluator (policy: / post_policy: walks Vec) +// ===================================================================== + +/// Walk an Effect list against the bag, dispatching PDP calls via `pdp` +/// and plugin invocations via `plugins`. Returns the phase's overall +/// decision. +/// +/// Semantics (DSL §3, §7.5): +/// - `Effect::When` — evaluate the condition; if true, run the body in +/// order with the same first-deny-wins logic. +/// - `Effect::Pdp` — call resolver; on Allow run `on_allow` reactions and +/// continue; on Deny run `on_deny` reactions and return the deny +/// (reactions can override with their own deny, but cannot turn a deny +/// into an allow). +/// - `Effect::Plugin` — invoke; Allow continues, Deny returns. +/// - `Effect::Delegate` — mint downstream credential; writes +/// `delegation.granted.*` keys back into the bag; deny-on-failure unless +/// the step's `on_error` overrides. +/// - `Effect::Taint` — record the label; never halts. +/// - `Effect::FieldOp` — apply a pipe chain to `args.X` / `result.X`; +/// may set `args_modified` / `result_modified`. +/// - `Effect::Sequential` — run children in order, halt on first Deny. +/// - `Effect::Parallel` — run children concurrently, abort on first Deny. +/// - `Effect::Allow` — explicit no-op; continues the phase. +/// - `Effect::Deny` — halt with the supplied reason/code. +/// +/// PDP / plugin errors map to a Deny with the error in the reason, per +/// the design's fail-closed default (DSL §8.9). Pre-E4 `evaluate_steps` +/// is preserved as a deprecated alias that forwards here. +#[allow(clippy::too_many_arguments)] +pub async fn evaluate_effects( + effects: &[Effect], + bag: &mut AttributeBag, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, + phase: crate::step::DispatchPhase, + payload: &mut crate::route::RoutePayload, +) -> StepsEvaluation { + let mut taints: Vec = Vec::new(); + let mut args_modified = false; + let mut result_modified = false; + for effect in effects { + // Each top-level effect runs against the shared mutable state. + // `Effect::When` / `Effect::Pdp` handle their own internal + // walking via dispatch_effect's recursive call. + let fallback_source = match effect { + Effect::When { source, .. } => source.as_str(), + _ => "", + }; + match Box::pin(dispatch_effect( + effect, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + &mut taints, + &mut args_modified, + &mut result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => {} + EffectOutcome::Halt(decision) => { + return StepsEvaluation::deny(decision, taints, args_modified, result_modified); + } + } + } + StepsEvaluation { + decision: Decision::Allow, + taints, + args_modified, + result_modified, + } +} + +/// Outcome of `evaluate_effects`: the phase's decision plus taints emitted +/// by any plugin steps that ran. Taints are accumulated even when the +/// phase ultimately denies — audit needs to see what the plugins +/// reported before the deny landed. Empty `taints` is the common case +/// (most steps are predicates / PDP calls, not label emitters). +/// +/// `args_modified` / `result_modified` are set when an `Effect::FieldOp` +/// inside a `do:` body successfully mutated the route payload — the +/// orchestrator uses them to OR-into the route-level "did anything +/// change" signals so the host knows to re-serialize the body. +#[derive(Debug, Clone)] +pub struct StepsEvaluation { + pub decision: Decision, + pub taints: Vec, + pub args_modified: bool, + pub result_modified: bool, +} + +impl StepsEvaluation { + fn deny( + d: Decision, + taints: Vec, + args_modified: bool, + result_modified: bool, + ) -> Self { + Self { + decision: d, + taints, + args_modified, + result_modified, + } + } +} + +/// Outcome of dispatching one effect. Internal control-flow signal — +/// never serialized, never exposed in the IR. Sits between the per- +/// effect dispatch (When / Pdp / Plugin / Delegate / Taint / Allow / +/// Deny / FieldOp / Sequential / Parallel) and the caller's "do I keep +/// walking the effects list or halt?" loop. +enum EffectOutcome { + /// Effect completed without producing a Deny — caller moves on to + /// the next effect in the surrounding list. + Continue, + /// Effect produced a Deny decision — caller halts the rest of the + /// surrounding list, the rest of the phase, and the route. + Halt(Decision), +} + +/// Run a single effect against the evaluator's state. Called by both +/// `evaluate_effects` (top-level walk of `policy:` / `post_policy:`) +/// and by recursive arms (Sequential, Parallel, When body, Pdp +/// reactions), so there's exactly one place that knows how each +/// effect kind dispatches. +/// +/// `fallback_source` is the rule-source-position string used as the +/// `rule_source` field on a `Decision::Deny` when the effect itself +/// doesn't carry an explicit code (i.e. `Effect::Deny { code: None }`, +/// or a deny coming back from a plugin / delegator without overriding +/// the default). +#[allow(clippy::too_many_arguments)] +async fn dispatch_effect( + effect: &Effect, + fallback_source: &str, + bag: &mut AttributeBag, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, + phase: crate::step::DispatchPhase, + taints: &mut Vec, + args_modified: &mut bool, + result_modified: &mut bool, + payload: &mut crate::route::RoutePayload, +) -> EffectOutcome { + match effect { + Effect::Allow => EffectOutcome::Continue, + + Effect::Deny { reason, code } => { + // Author-supplied code overrides the auto-generated source + // position. Lets MCP clients dispatch on stable categories + // (`quota.exceeded`) rather than positional codes that + // shift with YAML edits. + let rule_source = code + .clone() + .unwrap_or_else(|| fallback_source.to_string()); + EffectOutcome::Halt(Decision::Deny { + reason: reason.clone(), + rule_source, + }) + } + + Effect::Plugin { name } => { + match plugins + .invoke(name, bag, PluginInvocation::Step { phase }) + .await + { + Ok(outcome) => { + // Plugins can emit taints regardless of decision — + // collect first, then act on the decision. + taints.extend(outcome.taints); + match outcome.decision { + Decision::Allow => EffectOutcome::Continue, + deny @ Decision::Deny { .. } => EffectOutcome::Halt(deny), + } + } + Err(e) => EffectOutcome::Halt(Decision::Deny { + reason: Some(format!("plugin `{}` error: {}", name, e)), + rule_source: format!("plugin:{}", name), + }), + } + } + + Effect::Delegate(delegate_step) => { + match delegations.delegate(delegate_step).await { + Ok(outcome) => match &outcome.decision { + Decision::Allow => { + // Surface granted_* keys into the bag so + // downstream rules in this same step list can + // read them (`require(delegation.granted.permissions + // contains "X")`, etc.). + use crate::attributes::AttributeValue; + use crate::step::delegation_bag_keys as bk; + + bag.set(bk::GRANTED, AttributeValue::Bool(true)); + if !outcome.granted_permissions.is_empty() { + let set: std::collections::HashSet = + outcome.granted_permissions.iter().cloned().collect(); + bag.set( + bk::GRANTED_PERMISSIONS, + AttributeValue::StringSet(set), + ); + } + if let Some(aud) = &outcome.granted_audience { + bag.set(bk::GRANTED_AUDIENCE, aud.clone()); + } + if let Some(exp) = &outcome.granted_expires_at { + bag.set(bk::GRANTED_EXPIRES_AT, exp.clone()); + } + EffectOutcome::Continue + } + Decision::Deny { .. } => { + // Apply the step's on_error policy. Default + // ("deny") halts; "continue" lets the pipeline + // keep going so subsequent rules can branch on + // the absent `delegation.granted` flag. + let on_error = delegate_step + .on_error + .as_deref() + .unwrap_or("deny") + .to_ascii_lowercase(); + if on_error == "continue" { + EffectOutcome::Continue + } else { + EffectOutcome::Halt(outcome.decision) + } + } + }, + Err(e) => { + // Transport / lookup failure. on_error treats this + // the same way as a plugin-side deny. + let on_error = delegate_step + .on_error + .as_deref() + .unwrap_or("deny") + .to_ascii_lowercase(); + if on_error == "continue" { + EffectOutcome::Continue + } else { + EffectOutcome::Halt(Decision::Deny { + reason: Some(format!( + "delegate `{}` error: {}", + delegate_step.plugin_name, e + )), + rule_source: delegate_step.source.clone(), + }) + } + } + } + } + + Effect::Taint { label, scopes } => { + // Emit the taint into the phase's accumulator so it flows + // into `RouteDecision.taints`. Apl-cpex's invoker handles + // the session-store persistence side at request end — here + // we only record the event. Scopes come straight from the + // parser (`taint(label, session, message)` syntax). + taints.push(crate::pipeline::TaintEvent { + label: label.clone(), + scopes: scopes.clone(), + }); + EffectOutcome::Continue + } + + Effect::FieldOp { path, stages } => { + dispatch_field_op( + path, + stages, + fallback_source, + bag, + plugins, + phase, + taints, + args_modified, + result_modified, + payload, + ) + .await + } + + Effect::Sequential(effects) => { + // Semantically the same as inlining the list into the + // enclosing scope — walk in order, stop on first Halt. + // The variant exists for explicit grouping and to pair + // with `Parallel` in the IR. + for inner in effects { + match Box::pin(dispatch_effect( + inner, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => continue, + halt @ EffectOutcome::Halt(_) => return halt, + } + } + EffectOutcome::Continue + } + + Effect::Parallel(effects) => { + // `dispatch_parallel` returns an explicit `BoxFuture<'_, _>` + // (Send by construction) so the recursive + // dispatch_effect → dispatch_parallel → dispatch_effect + // chain doesn't trip the compiler's Send-inference cycle. + dispatch_parallel( + effects, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + payload, + ) + .await + } + + Effect::When { condition, body, source } => { + // Predicate-gated body — replaces the historical + // `Step::Rule`. Skip silently when the condition is false; + // otherwise walk the body in order and halt on first Deny. + if !eval_expression(condition, bag) { + return EffectOutcome::Continue; + } + for inner in body { + match Box::pin(dispatch_effect( + inner, + source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => continue, + halt @ EffectOutcome::Halt(_) => return halt, + } + } + EffectOutcome::Continue + } + + Effect::Pdp { call, on_allow, on_deny } => { + // External PDP call — replaces `Step::Pdp`. Reactions run + // through the same dispatch_effect path (recursively). + match pdp.evaluate(call, bag).await { + Ok(pdp_result) => match pdp_result.decision { + Decision::Allow => { + // Walk on_allow; if it ends without a Halt the + // PDP allow stands and we continue. + for inner in on_allow { + match Box::pin(dispatch_effect( + inner, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + EffectOutcome::Continue => continue, + halt @ EffectOutcome::Halt(_) => return halt, + } + } + EffectOutcome::Continue + } + deny @ Decision::Deny { .. } => { + // Reactions can override the PDP's deny reason + // (e.g. `on_deny: [deny "..."]`) but cannot + // upgrade the deny to allow — if reactions + // walked clean, the PDP's original deny stands. + for inner in on_deny { + if let EffectOutcome::Halt(reaction_decision) = + Box::pin(dispatch_effect( + inner, + fallback_source, + bag, + pdp, + plugins, + delegations, + phase, + taints, + args_modified, + result_modified, + payload, + )) + .await + { + return EffectOutcome::Halt(reaction_decision); + } + } + EffectOutcome::Halt(deny) + } + }, + Err(e) => EffectOutcome::Halt(Decision::Deny { + reason: Some(format!("PDP error: {}", e)), + rule_source: format!("pdp:{:?}", call.dialect), + }), + } + } + } +} + +/// Run a list of effects concurrently. Each branch gets its own +/// cloned bag and payload — mutations inside a branch don't +/// propagate back to the shared outer state. Taints from every +/// branch are merged into the outer `taints` vec (taints are +/// append-only event logs, safe to concatenate). First Halt by +/// branch index wins; the remaining branches are aborted via +/// `cpex_orchestration::run_branches`'s `short_circuit_on_deny`. +/// +/// Config-load already rejected `FieldOp` / `Delegate` here via +/// [`Effect::validate_parallel_purity`], so at runtime we trust the +/// IR not to contain mutation effects. +/// +/// # Concurrency model (E3.2) +/// +/// Built on [`cpex_orchestration::run_branches`] — the same JoinSet +/// + abort-on-deny primitive `cpex-core`'s executor uses for its +/// concurrent phase. Each branch is `tokio::spawn`ed onto the +/// runtime, so branches get true OS-thread parallelism (vs. the v1 +/// implementation's `join_all`, which only interleaved on one +/// task). To meet the `'static + Send` bounds for spawning, the +/// invoker references are `&Arc` — we `Arc::clone` an +/// owned reference into each branch closure. +/// +/// Note: no per-branch timeout. The DSL doesn't expose one, and +/// plugin-level timeouts upstream of this call (in cpex-core's +/// executor) bound individual plugin invocations. If a route ever +/// needs a per-branch budget the orchestration crate already +/// supports `BranchConfig::timeout_per_branch` — wire it through a +/// `Effect::Parallel` extension if/when needed. +// Returns an explicit `BoxFuture` rather than `impl Future` so the +// caller (`dispatch_effect`'s `Effect::Parallel` arm, which is itself +// `async fn`) can break the Send-inference cycle this would otherwise +// introduce: dispatch_effect's opaque return type would depend on +// dispatch_parallel's, and dispatch_parallel spawns futures that +// recursively re-enter dispatch_effect. A concrete `BoxFuture` is +// `Pin>` — already Send by construction, +// no inference required. +fn dispatch_parallel<'a>( + effects: &'a [Effect], + fallback_source: &'a str, + bag: &'a AttributeBag, + pdp: &'a Arc, + plugins: &'a Arc, + delegations: &'a Arc, + phase: crate::step::DispatchPhase, + taints: &'a mut Vec, + payload: &'a crate::route::RoutePayload, +) -> futures::future::BoxFuture<'a, EffectOutcome> { + Box::pin(async move { + use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch}; + + if effects.is_empty() { + return EffectOutcome::Continue; + } + + // Build one spawn-ready branch future per effect. Each branch + // owns: + // * a cloned bag and payload — branch mutations stay local; + // * cloned Arcs to the invokers — `'static + Send`, ready for + // `tokio::spawn`; + // * an owned copy of the effect to evaluate (clone is cheap + // for the variants `Parallel` can hold: Allow, Deny, Plugin, + // Taint, Sequential, Parallel, When, Pdp). + let mut branches: Vec)>> = + Vec::with_capacity(effects.len()); + for effect in effects.iter() { + let effect = effect.clone(); + let fallback = fallback_source.to_string(); + let mut branch_bag = bag.clone(); + let mut branch_payload = payload.clone(); + let pdp = Arc::clone(pdp); + let plugins = Arc::clone(plugins); + let delegations = Arc::clone(delegations); + branches.push(Box::pin(async move { + let mut branch_taints: Vec = Vec::new(); + let mut branch_args_modified = false; + let mut branch_result_modified = false; + let outcome = Box::pin(dispatch_effect( + &effect, + &fallback, + &mut branch_bag, + &pdp, + &plugins, + &delegations, + phase, + &mut branch_taints, + &mut branch_args_modified, + &mut branch_result_modified, + &mut branch_payload, + )) + .await; + (outcome, branch_taints) + })); + } + + // `is_deny` short-circuits the moment any branch returns + // `EffectOutcome::Halt(_)`. The remaining branches get + // `BranchOutcome::Aborted` and we drop their (already-cancelled) + // futures. Taints from already-completed branches still land. + let cfg = BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: true, + }; + let outcomes = run_branches( + branches, + cfg, + |v: &(EffectOutcome, Vec)| { + matches!(v.0, EffectOutcome::Halt(_)) + }, + ) + .await; + + // Aggregate in input order: append every branch's taints; pick + // the first Halt (by branch index, not wall-clock order) as the + // overall result. Aborted / panicked branches contribute no + // taints — they didn't run to completion. A panicked branch is + // *not* converted into a Halt; we log via `tracing::warn!` and + // continue. (A misbehaving plugin shouldn't take down the + // parallel block any more than it would the host process.) + let mut first_halt: Option = None; + for (idx, outcome) in outcomes.into_iter().enumerate() { + match outcome { + BranchOutcome::Completed((effect_outcome, branch_taints)) => { + taints.extend(branch_taints); + if first_halt.is_none() { + if let EffectOutcome::Halt(d) = effect_outcome { + first_halt = Some(d); + } + } + } + BranchOutcome::Aborted => { + // Short-circuit cancelled this branch — intentional, + // no diagnostic needed. + } + BranchOutcome::TimedOut => { + // Unreachable today (no per-branch timeout + // configured). Treat as a no-op if it ever fires + // post-config-extension. + } + BranchOutcome::Panicked(msg) => { + // A panicking branch is a misbehaving plugin/effect; + // dropping its output (no Halt, no taints) keeps the + // parallel block's other branches intact rather than + // taking the whole block down. apl-core has no + // tracing dep — host integrations that care can + // surface the panic via cpex-core's plugin error + // path. `idx`/`msg` are eaten here. + let _ = (idx, msg); + } + } + } + + match first_halt { + Some(d) => EffectOutcome::Halt(d), + None => EffectOutcome::Continue, + } + }) +} + +/// Apply a `FieldOp` effect — resolve the path in args/result, run +/// the pipeline stages, write the outcome back into the payload. +/// +/// Out-of-phase ops are silent no-ops: a Pre-phase rule with +/// `result.X | redact` skips because the result hasn't been produced +/// yet; a Post-phase rule with `args.X | redact` skips because the +/// args were already sent on the wire. This is intentional so the +/// same `when:`/`do:` rule body can be reused across phases without +/// the author needing to branch on phase. +/// +/// Missing fields skip silently too (same as the args:/result: phase +/// pipelines) — a pipeline can't transform what isn't there. If the +/// author needs presence semantics, that's a `require(exists(args.X))` +/// upstream of the `do:` body. +#[allow(clippy::too_many_arguments)] +async fn dispatch_field_op( + path: &str, + stages: &[crate::pipeline::Stage], + fallback_source: &str, + bag: &mut AttributeBag, + plugins: &Arc, + phase: crate::step::DispatchPhase, + taints: &mut Vec, + args_modified: &mut bool, + result_modified: &mut bool, + payload: &mut crate::route::RoutePayload, +) -> EffectOutcome { + use crate::route::{get_dotted, remove_dotted, set_dotted}; + use crate::step::DispatchPhase; + + // Pick the right side of the payload based on the path prefix. + // Out-of-phase ops drop silently (see the doc comment). + enum Side { Args, Result } + let (root, subpath, side) = if let Some(rest) = path.strip_prefix("args.") { + if !matches!(phase, DispatchPhase::Pre) { + return EffectOutcome::Continue; + } + (&mut payload.args, rest, Side::Args) + } else if let Some(rest) = path.strip_prefix("result.") { + if !matches!(phase, DispatchPhase::Post) { + return EffectOutcome::Continue; + } + let Some(result) = payload.result.as_mut() else { + return EffectOutcome::Continue; + }; + (result, rest, Side::Result) + } else { + return EffectOutcome::Halt(Decision::Deny { + reason: Some(format!( + "FieldOp path `{}` must start with `args.` or `result.`", + path + )), + rule_source: fallback_source.to_string(), + }); + }; + + let Some(current) = get_dotted(root, subpath).cloned() else { + return EffectOutcome::Continue; // missing field → silent no-op + }; + + let pipeline = crate::pipeline::Pipeline { stages: stages.to_vec() }; + let eval = evaluate_pipeline(&pipeline, ¤t, bag, plugins, path, phase).await; + taints.extend(eval.taints); + let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side { + Side::Args => *args = true, + Side::Result => *result = true, + }; + match eval.outcome { + FieldOutcome::Pass => EffectOutcome::Continue, + FieldOutcome::Replace(new_val) => { + if set_dotted(root, subpath, new_val) { + mark_modified(side, args_modified, result_modified); + } + EffectOutcome::Continue + } + FieldOutcome::Omit => { + if remove_dotted(root, subpath) { + mark_modified(side, args_modified, result_modified); + } + EffectOutcome::Continue + } + FieldOutcome::Deny { reason, stage_index: _ } => EffectOutcome::Halt(Decision::Deny { + reason: Some(reason), + rule_source: fallback_source.to_string(), + }), + } +} + +// ===================================================================== +// Pipe-chain evaluator (args: / result: field pipelines) +// ===================================================================== + +/// Result of running a pipeline against one field's value. +/// +/// `Pass`: every stage succeeded; the original value should be kept. +/// `Replace`: a transform produced a new value (also covers conditional +/// `redact` firing). +/// `Omit`: an `omit` stage fired; the field should be dropped from output. +/// `Deny`: a validator failed; pipeline halted; the route should deny. +#[derive(Debug, Clone, PartialEq)] +pub enum FieldOutcome { + Pass, + Replace(serde_json::Value), + Omit, + Deny { reason: String, stage_index: usize }, +} + +/// Full result of a pipeline run: value-level outcome plus accumulated +/// taint side effects. +/// +/// `taint(...)` stages, plugin invocations, and `scan(...)` stages can all +/// emit taints; the evaluator collects them here and hands them to the host +/// (apl-cpex) for SessionStore writes. Taints accumulate even on `Replace` +/// and `Omit` outcomes; they do not accumulate past a `Deny` (the pipeline +/// halts at the failing stage). +#[derive(Debug, Clone, PartialEq)] +pub struct PipelineEvaluation { + pub outcome: FieldOutcome, + pub taints: Vec, +} + +/// Walk a pipeline against `value` and the bag, applying stages left-to-right. +/// +/// Async because pipe-chain `plugin(name)` stages dispatch through +/// `PluginInvoker`, which is async. +/// +/// `field_name` is the field this pipeline is attached to (from the wrapping +/// `FieldRule`). It's threaded into `PluginInvocation::Field` when a +/// `Stage::Plugin` fires so the invoker knows which field is in focus. +/// Pass `""` for standalone pipeline runs that aren't part of a field rule. +/// +/// `Stage::Validate { name }` is currently a no-op with a TODO — the named +/// validator registry lands in a later step. +pub async fn evaluate_pipeline( + pipeline: &Pipeline, + value: &serde_json::Value, + bag: &AttributeBag, + plugins: &Arc, + field_name: &str, + phase: crate::step::DispatchPhase, +) -> PipelineEvaluation { + let mut current = value.clone(); + let mut replaced = false; + let mut taints: Vec = Vec::new(); + + for (idx, stage) in pipeline.stages.iter().enumerate() { + match stage { + // ----- Validators ----- + Stage::Type(tc) => { + if !type_check(tc, ¤t) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("expected {:?}, got {}", tc, value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Length { min, max } => { + let Some(s) = current.as_str() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("len(...) requires string value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + let len = s.chars().count(); + if min.map_or(false, |m| len < m) || max.map_or(false, |m| len > m) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("length {} outside [{:?}, {:?}]", len, min, max), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Range { min, max } => { + let Some(n) = current.as_i64() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("range requires integer value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + if min.map_or(false, |m| n < m) || max.map_or(false, |m| n > m) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("value {} outside [{:?}, {:?}]", n, min, max), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Enum { values } => { + let Some(s) = current.as_str() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("enum(...) requires string value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + if !values.iter().any(|v| v == s) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("value `{}` not in enum {:?}", s, values), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Regex { pattern } => { + // Compile-at-eval for now. A future step can swap to a + // route-level pre-compile cache keyed by pattern. + let re = match regex::Regex::new(pattern) { + Ok(r) => r, + Err(e) => { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("invalid regex `{}`: {}", pattern, e), + stage_index: idx, + }, + taints, + }; + } + }; + let Some(s) = current.as_str() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("regex requires string value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + if !re.is_match(s) { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("value did not match regex `{}`", pattern), + stage_index: idx, + }, + taints, + }; + } + } + Stage::Validate { name } => { + // Named-validator dispatch is not implemented in this + // build. The parser rejects `validate(...)` at compile + // time (parser.rs); this branch covers IR built + // programmatically bypassing the parser. Same shape + // as the parser's diagnostic — operators reach for + // `regex(...)` or `plugin(...)` instead. + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!( + "`validate({})` is not implemented; use `regex(...)` \ + or `plugin({})` instead", + name, name, + ), + stage_index: idx, + }, + taints, + }; + } + + // ----- Transforms ----- + Stage::Mask { keep_last } => { + let Some(s) = current.as_str() else { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("mask(...) requires string value, got {}", value_kind(¤t)), + stage_index: idx, + }, + taints, + }; + }; + let chars: Vec = s.chars().collect(); + let keep = (*keep_last).min(chars.len()); + let mask_count = chars.len() - keep; + let masked: String = std::iter::repeat('*').take(mask_count) + .chain(chars.into_iter().skip(mask_count)) + .collect(); + current = serde_json::Value::String(masked); + replaced = true; + } + Stage::Redact { condition } => { + let should_redact = match condition { + None => true, + Some(expr) => eval_expression(expr, bag), + }; + if should_redact { + current = serde_json::Value::String("[REDACTED]".into()); + replaced = true; + } + } + Stage::Omit => { + return PipelineEvaluation { outcome: FieldOutcome::Omit, taints }; + } + Stage::Hash => { + // Simple deterministic digest — DefaultHasher is fine for + // de-identification (not for cryptographic use). + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + value_for_hash(¤t).hash(&mut h); + current = serde_json::Value::String(format!("hash:{:016x}", h.finish())); + replaced = true; + } + + // ----- Effects ----- + Stage::Taint { label, scopes } => { + taints.push(TaintEvent { label: label.clone(), scopes: scopes.clone() }); + } + Stage::Plugin { name } => { + let invocation = PluginInvocation::Field { + name: field_name, + value: ¤t, + phase, + }; + match plugins.invoke(name, bag, invocation).await { + Ok(outcome) => { + // Plugins can emit taints regardless of decision. + taints.extend(outcome.taints); + match outcome.decision { + Decision::Allow => { + if let Some(new_value) = outcome.modified_value { + current = new_value; + replaced = true; + } + } + Decision::Deny { reason, rule_source: _ } => { + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: reason.unwrap_or_else( + || format!("plugin `{}` denied", name), + ), + stage_index: idx, + }, + taints, + }; + } + } + } + Err(e) => { + // Fail-closed: plugin dispatch failure halts the pipeline. + return PipelineEvaluation { + outcome: FieldOutcome::Deny { + reason: format!("plugin `{}` error: {}", name, e), + stage_index: idx, + }, + taints, + }; + } + } + } + Stage::Scan { kind } => { + // Spec mapping (apl-dsl-spec §4): scan stages are taint + // emitters. The actual PII detection / injection signal + // lives in plugin(...) variants of the same scanners; this + // stage just records the label so downstream policies can + // gate on it. `pii.redact` additionally rewrites the value. + let (label, redact): (&str, bool) = match kind { + ScanKind::PiiDetect => ("PII", false), + ScanKind::PiiRedact => ("PII", true), + ScanKind::InjectionScan => ("injection", false), + }; + taints.push(TaintEvent { + label: label.to_string(), + scopes: vec![TaintScope::Session], + }); + if redact { + current = serde_json::Value::String("[REDACTED]".into()); + replaced = true; + } + } + } + } + + let outcome = if replaced { + FieldOutcome::Replace(current) + } else { + FieldOutcome::Pass + }; + PipelineEvaluation { outcome, taints } +} + + +fn type_check(tc: &TypeCheck, v: &serde_json::Value) -> bool { + match tc { + TypeCheck::Str => v.is_string(), + TypeCheck::Int => v.is_i64(), + TypeCheck::Bool => v.is_boolean(), + TypeCheck::Float => v.is_f64() || v.is_i64(), + TypeCheck::Email => v.as_str().map_or(false, |s| s.contains('@') && s.contains('.')), + TypeCheck::Url => v.as_str().map_or(false, |s| s.starts_with("http://") || s.starts_with("https://")), + TypeCheck::Uuid => v.as_str().map_or(false, is_uuid_shape), + } +} + +fn is_uuid_shape(s: &str) -> bool { + // 8-4-4-4-12 hex with `-` separators. + let bytes = s.as_bytes(); + if bytes.len() != 36 { return false; } + for (i, &b) in bytes.iter().enumerate() { + match i { + 8 | 13 | 18 | 23 => if b != b'-' { return false; }, + _ => if !b.is_ascii_hexdigit() { return false; }, + } + } + true +} + +fn value_kind(v: &serde_json::Value) -> &'static str { + match v { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(n) if n.is_i64() => "int", + serde_json::Value::Number(_) => "float", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } +} + +/// Stable byte representation of a value for hashing — serde_json's +/// `to_string` is canonical enough for our use. +fn value_for_hash(v: &serde_json::Value) -> String { + serde_json::to_string(v).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rules::{ Condition, Expression, Rule}; + use crate::step::{DelegationInvoker, NoopDelegationInvoker}; + use std::collections::HashSet; + use std::sync::Arc; + + fn rule(condition: Expression, effect: Effect, source: &str) -> Rule { + Rule::single(condition, effect, source) + } + + // Wrap stateless test invokers in `Arc` once per call. The + // public evaluator API takes `&Arc` so internal + // dispatch (notably `Effect::Parallel`) can `Arc::clone` an owned, + // 'static reference into each spawned branch (slice E3.2). + fn null_pipe_plugins() -> Arc { + Arc::new(NullPipelinePlugins) + } + fn null_plugins() -> Arc { + Arc::new(NullPlugins) + } + fn noop_delegations() -> Arc { + Arc::new(NoopDelegationInvoker) + } + + fn deny(reason: &str) -> Effect { + Effect::Deny { reason: Some(reason.into()), code: None } + } + + fn cond(c: Condition) -> Expression { + Expression::Condition(c) + } + + // ----- Decision-level semantics ----- + + #[test] + fn empty_rules_allow() { + let mut bag = AttributeBag::new(); + assert_eq!(evaluate_rules(&[], &bag), Decision::Allow); + } + + #[test] + fn first_deny_halts() { + let mut bag = AttributeBag::new(); + bag.set("a", true); + bag.set("b", true); + + let rules = vec![ + rule(cond(Condition::IsTrue { key: "a".into() }), deny("first"), "r0"), + rule(cond(Condition::IsTrue { key: "b".into() }), deny("second"), "r1"), + ]; + + match evaluate_rules(&rules, &bag) { + Decision::Deny { reason, rule_source } => { + assert_eq!(reason.as_deref(), Some("first")); + assert_eq!(rule_source, "r0"); + } + d => panic!("expected Deny, got {:?}", d), + } + } + + #[test] + fn allow_does_not_short_circuit() { + // Spec §3: explicit allow continues evaluation. A later deny still fires. + let mut bag = AttributeBag::new(); + bag.set("ok", true); + bag.set("bad", true); + + let rules = vec![ + rule(cond(Condition::IsTrue { key: "ok".into() }), Effect::Allow, "r0_allow"), + rule(cond(Condition::IsTrue { key: "bad".into() }), deny("later"), "r1_deny"), + ]; + + match evaluate_rules(&rules, &bag) { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r1_deny"), + d => panic!("allow short-circuited; expected later deny, got {:?}", d), + } + } + + #[test] + fn unmatched_rules_dont_fire() { + let mut bag = AttributeBag::new(); // "denied" missing → false + let rules = vec![rule( + cond(Condition::IsTrue { key: "denied".into() }), + deny("shouldn't fire"), + "r0", + )]; + assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow); + } + + // ----- Predicate semantics ----- + + #[test] + fn missing_key_is_false() { + let mut bag = AttributeBag::new(); + assert!(!eval_condition(&Condition::IsTrue { key: "missing".into() }, &bag)); + assert!(eval_condition(&Condition::IsFalse { key: "missing".into() }, &bag)); + // Comparison on missing → false (spec §2.6). + assert!(!eval_condition( + &Condition::Comparison { + key: "missing".into(), + op: CompareOp::Eq, + value: 1_i64.into(), + }, + &bag, + )); + } + + #[test] + fn and_or_not_combinators() { + let mut bag = AttributeBag::new(); + bag.set("a", true); + bag.set("b", false); + + let a = cond(Condition::IsTrue { key: "a".into() }); + let b = cond(Condition::IsTrue { key: "b".into() }); + + assert!(eval_expression(&Expression::And(vec![a.clone(), a.clone()]), &bag)); + assert!(!eval_expression(&Expression::And(vec![a.clone(), b.clone()]), &bag)); + assert!(eval_expression(&Expression::Or(vec![a.clone(), b.clone()]), &bag)); + assert!(!eval_expression(&Expression::Or(vec![b.clone(), b.clone()]), &bag)); + assert!(eval_expression(&Expression::Not(Box::new(b)), &bag)); + } + + // ----- Comparison operators ----- + + #[test] + fn int_comparisons() { + let mut bag = AttributeBag::new(); + bag.set("delegation.depth", 3_i64); + + let cmp = |op| Condition::Comparison { + key: "delegation.depth".into(), + op, + value: 2_i64.into(), + }; + assert!(eval_condition(&cmp(CompareOp::Gt), &bag)); + assert!(eval_condition(&cmp(CompareOp::GtEq), &bag)); + assert!(!eval_condition(&cmp(CompareOp::Lt), &bag)); + assert!(!eval_condition(&cmp(CompareOp::Eq), &bag)); + assert!(eval_condition(&cmp(CompareOp::NotEq), &bag)); + } + + #[test] + fn int_to_float_promotion_in_comparison() { + let mut bag = AttributeBag::new(); + bag.set("delegation.depth", 2_i64); + // `delegation.depth > 2.5` — int promotes to float for the compare. + assert!(!eval_condition( + &Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Gt, + value: 2.5_f64.into(), + }, + &bag, + )); + assert!(eval_condition( + &Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Lt, + value: 2.5_f64.into(), + }, + &bag, + )); + } + + #[test] + fn string_equality_no_ordering() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + + assert!(eval_condition( + &Condition::Comparison { + key: "subject.id".into(), + op: CompareOp::Eq, + value: "alice".into(), + }, + &bag, + )); + // Order operators on strings → false (spec §2.3). + assert!(!eval_condition( + &Condition::Comparison { + key: "subject.id".into(), + op: CompareOp::Gt, + value: "alice".into(), + }, + &bag, + )); + } + + #[test] + fn contains_set_membership() { + let mut bag = AttributeBag::new(); + bag.set( + "session.labels", + HashSet::from(["PII".to_string(), "financial".to_string()]), + ); + + assert!(eval_condition( + &Condition::Comparison { + key: "session.labels".into(), + op: CompareOp::Contains, + value: "PII".into(), + }, + &bag, + )); + assert!(!eval_condition( + &Condition::Comparison { + key: "session.labels".into(), + op: CompareOp::Contains, + value: "PHI".into(), + }, + &bag, + )); + // Contains on a non-set attribute → false. + bag.set("subject.id", "alice"); + assert!(!eval_condition( + &Condition::Comparison { + key: "subject.id".into(), + op: CompareOp::Contains, + value: "alice".into(), + }, + &bag, + )); + } + + // ----- Realistic end-to-end ----- + + #[test] + fn hr_compensation_scenario() { + // From the HR demo: alice (hr role + view_ssn perm) requests compensation + // with delegation.depth = 1. Rules: + // 1. require(authenticated) + // 2. require(role.hr | role.finance) + // 3. delegation.depth > 2 & include_ssn: deny + // 4. !perm.view_ssn & include_ssn: deny + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("role.hr", true); + bag.set("perm.view_ssn", true); + bag.set("delegation.depth", 1_i64); + bag.set("include_ssn", true); + + let rules = vec![ + // require(authenticated) → deny if !authenticated + rule( + Expression::Not(Box::new(cond(Condition::IsTrue { + key: "authenticated".into(), + }))), + deny("not authenticated"), + "r0", + ), + // require(role.hr | role.finance) → deny if neither + // Desugars to: when !(role.hr | role.finance) do deny + // = when (role.hr is false) AND (role.finance is false), deny + rule( + Expression::And(vec![ + cond(Condition::IsFalse { key: "role.hr".into() }), + cond(Condition::IsFalse { key: "role.finance".into() }), + ]), + deny("not in hr/finance"), + "r1", + ), + // delegation.depth > 2 & include_ssn: deny + rule( + Expression::And(vec![ + cond(Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Gt, + value: 2_i64.into(), + }), + cond(Condition::IsTrue { key: "include_ssn".into() }), + ]), + deny("delegation too deep for SSN"), + "r2", + ), + ]; + + assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow); + + // Now make Alice undelegated-but-deep — should still allow at depth=1. + // Change to depth=3 and the SSN rule fires. + bag.set("delegation.depth", 3_i64); + match evaluate_rules(&rules, &bag) { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r2"), + d => panic!("expected r2 deny, got {:?}", d), + } + } + + // =================================================================== + // Pipe-chain evaluator tests + // =================================================================== + + use crate::pipeline::{Stage, TypeCheck}; + use serde_json::json; + + fn make_pipeline(stages: Vec) -> crate::pipeline::Pipeline { + crate::pipeline::Pipeline { stages } + } + + // Helper: a plugin invoker that's never expected to fire (pipelines + // without `plugin(...)` stages). Panics if called. Defined alongside + // the other null fixtures further down in this module. + + async fn run_pipeline( + p: &crate::pipeline::Pipeline, + v: &serde_json::Value, + bag: &AttributeBag, + ) -> FieldOutcome { + evaluate_pipeline(p, v, bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await.outcome + } + + /// Pipeline-test null invoker — distinct from the step-test `NullPlugins` + /// so each test can panic with a clearer "wrong fixture" message if it + /// ever does dispatch a plugin call by accident. + struct NullPipelinePlugins; + #[async_trait] + impl PluginInvoker for NullPipelinePlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + panic!("NullPipelinePlugins should not dispatch; got plugin({})", name); + } + } + + #[tokio::test] + async fn pipeline_empty_is_pass() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![]); + assert_eq!(run_pipeline(&p, &json!("anything"), &bag).await, FieldOutcome::Pass); + } + + #[tokio::test] + async fn pipeline_type_check_passes_and_denies() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Type(TypeCheck::Str)]); + assert_eq!(run_pipeline(&p, &json!("hello"), &bag).await, FieldOutcome::Pass); + match run_pipeline(&p, &json!(42), &bag).await { + FieldOutcome::Deny { reason, stage_index } => { + assert!(reason.contains("expected Str")); + assert_eq!(stage_index, 0); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_mask_preserves_last_n() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]); + match run_pipeline(&p, &json!("123-45-6789"), &bag).await { + FieldOutcome::Replace(v) => assert_eq!(v, json!("*******6789")), + other => panic!("expected Replace, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_mask_handles_short_strings() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]); + // keep_last >= length → no mask chars; full string preserved. + match run_pipeline(&p, &json!("ab"), &bag).await { + FieldOutcome::Replace(v) => assert_eq!(v, json!("ab")), + other => panic!("expected Replace, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_unconditional_redact() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Redact { condition: None }]); + match run_pipeline(&p, &json!("secret"), &bag).await { + FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")), + other => panic!("expected Replace, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_conditional_redact_fires_when_condition_true() { + // redact(!perm.view_ssn): condition is `!perm.view_ssn`. Missing key + // → IsTrue is false → `!IsTrue` is true → redact fires. + let mut bag = AttributeBag::new(); + let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue { + key: "perm.view_ssn".into(), + }))); + let p = make_pipeline(vec![Stage::Redact { condition: Some(cond) }]); + match run_pipeline(&p, &json!("123-45-6789"), &bag).await { + FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")), + other => panic!("expected Replace (redact fired), got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_conditional_redact_skips_when_condition_false() { + let mut bag = AttributeBag::new(); + bag.set("perm.view_ssn", true); + let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue { + key: "perm.view_ssn".into(), + }))); + let p = make_pipeline(vec![Stage::Redact { condition: Some(cond) }]); + // perm.view_ssn=true → !true=false → redact skipped → Pass. + assert_eq!( + run_pipeline(&p, &json!("123-45-6789"), &bag).await, + FieldOutcome::Pass, + ); + } + + #[tokio::test] + async fn pipeline_omit_short_circuits() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Omit, + // This stage should never run. + Stage::Type(TypeCheck::Int), + ]); + assert_eq!(run_pipeline(&p, &json!("anything"), &bag).await, FieldOutcome::Omit); + } + + #[tokio::test] + async fn pipeline_range_validator() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Int), + Stage::Range { min: Some(0), max: Some(1_000_000) }, + ]); + assert_eq!(run_pipeline(&p, &json!(500_000), &bag).await, FieldOutcome::Pass); + // Above max → deny. + match run_pipeline(&p, &json!(2_000_000), &bag).await { + FieldOutcome::Deny { reason, stage_index } => { + assert!(reason.contains("outside")); + assert_eq!(stage_index, 1); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_length_validator() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Length { min: None, max: Some(5) }]); + assert_eq!(run_pipeline(&p, &json!("hi"), &bag).await, FieldOutcome::Pass); + assert!(matches!( + run_pipeline(&p, &json!("too long"), &bag).await, + FieldOutcome::Deny { .. }, + )); + } + + #[tokio::test] + async fn pipeline_enum_validator() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Enum { + values: vec!["low".into(), "medium".into(), "high".into()], + }]); + assert_eq!(run_pipeline(&p, &json!("medium"), &bag).await, FieldOutcome::Pass); + assert!(matches!( + run_pipeline(&p, &json!("extreme"), &bag).await, + FieldOutcome::Deny { .. }, + )); + } + + #[tokio::test] + async fn pipeline_uuid_validator() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Type(TypeCheck::Uuid)]); + assert_eq!( + run_pipeline(&p, &json!("550e8400-e29b-41d4-a716-446655440000"), &bag).await, + FieldOutcome::Pass, + ); + assert!(matches!( + run_pipeline(&p, &json!("not-a-uuid"), &bag).await, + FieldOutcome::Deny { .. }, + )); + } + + #[tokio::test] + async fn pipeline_hash_replaces_value() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Hash]); + match run_pipeline(&p, &json!("secret"), &bag).await { + FieldOutcome::Replace(v) => { + let s = v.as_str().unwrap(); + assert!(s.starts_with("hash:")); + assert_eq!(s.len(), "hash:".len() + 16); + } + other => panic!("expected Replace, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_validate_named_denies_at_runtime() { + // `validate(name)` is unimplemented in this build. The parser + // rejects it at compile time; this test exercises the runtime + // defense-in-depth path for IR built programmatically. The + // deny message points operators at the working alternatives + // (`regex(...)` / `plugin(...)`). + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Str), + Stage::Validate { name: "ssn_format".into() }, + Stage::Mask { keep_last: 4 }, + ]); + match run_pipeline(&p, &json!("123-45-6789"), &bag).await { + FieldOutcome::Deny { reason, stage_index } => { + assert_eq!(stage_index, 1, "validate stage is at index 1"); + assert!( + reason.contains("not implemented"), + "deny reason should explain that validate is unimplemented: {reason}", + ); + assert!( + reason.contains("regex") || reason.contains("plugin"), + "deny reason should point at alternatives: {reason}", + ); + } + other => panic!("expected Deny on validate(...) stage, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_validator_short_circuits_before_transform() { + // If the validator fails, the transform never runs. + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Int), // will fail on a string + Stage::Mask { keep_last: 4 }, + ]); + match run_pipeline(&p, &json!("hello"), &bag).await { + FieldOutcome::Deny { stage_index, .. } => assert_eq!(stage_index, 0), + other => panic!("expected Deny at stage 0, got {:?}", other), + } + } + + // ----- Regex stage ----- + + #[tokio::test] + async fn pipeline_regex_match_passes() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Regex { + pattern: r"^\d{3}-\d{2}-\d{4}$".into(), + }]); + assert_eq!(run_pipeline(&p, &json!("123-45-6789"), &bag).await, FieldOutcome::Pass); + } + + #[tokio::test] + async fn pipeline_regex_no_match_denies() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Regex { + pattern: r"^\d{3}-\d{2}-\d{4}$".into(), + }]); + match run_pipeline(&p, &json!("not an ssn"), &bag).await { + FieldOutcome::Deny { reason, stage_index } => { + assert!(reason.contains("did not match")); + assert_eq!(stage_index, 0); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_regex_invalid_pattern_denies() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Regex { pattern: "(unclosed".into() }]); + match run_pipeline(&p, &json!("anything"), &bag).await { + FieldOutcome::Deny { reason, .. } => { + assert!(reason.contains("invalid regex")); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_regex_non_string_denies() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Regex { pattern: r"^\d+$".into() }]); + match run_pipeline(&p, &json!(42), &bag).await { + FieldOutcome::Deny { reason, .. } => { + assert!(reason.contains("requires string")); + } + other => panic!("expected Deny on non-string regex input, got {:?}", other), + } + } + + // ----- Taint and Scan stages ----- + + #[tokio::test] + async fn pipeline_taint_records_event() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Str), + Stage::Taint { label: "PII".into(), scopes: vec![TaintScope::Session] }, + Stage::Mask { keep_last: 4 }, + ]); + let result = evaluate_pipeline(&p, &json!("123-45-6789"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789"))); + assert_eq!(result.taints, vec![TaintEvent { + label: "PII".into(), + scopes: vec![TaintScope::Session], + }]); + } + + #[tokio::test] + async fn pipeline_scan_pii_detect_emits_taint() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Scan { kind: ScanKind::PiiDetect }]); + let result = evaluate_pipeline(&p, &json!("some text"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + // PII detect: value unchanged, one taint event emitted. + assert_eq!(result.outcome, FieldOutcome::Pass); + assert_eq!(result.taints, vec![TaintEvent { + label: "PII".into(), + scopes: vec![TaintScope::Session], + }]); + } + + #[tokio::test] + async fn pipeline_scan_pii_redact_replaces_and_taints() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Scan { kind: ScanKind::PiiRedact }]); + let result = evaluate_pipeline(&p, &json!("123-45-6789"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Replace(json!("[REDACTED]"))); + assert_eq!(result.taints.len(), 1); + assert_eq!(result.taints[0].label, "PII"); + } + + #[tokio::test] + async fn pipeline_scan_injection_emits_injection_taint() { + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![Stage::Scan { kind: ScanKind::InjectionScan }]); + let result = evaluate_pipeline(&p, &json!("user input"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Pass); + assert_eq!(result.taints[0].label, "injection"); + } + + #[tokio::test] + async fn pipeline_deny_does_not_accumulate_later_taints() { + // Pipeline halts at the first failing validator; taints emitted + // before the failure stick, taints after do not. + let mut bag = AttributeBag::new(); + let p = make_pipeline(vec![ + Stage::Taint { label: "before".into(), scopes: vec![TaintScope::Session] }, + Stage::Type(TypeCheck::Int), // fails on string input + Stage::Taint { label: "after".into(), scopes: vec![TaintScope::Session] }, + ]); + let result = evaluate_pipeline(&p, &json!("hello"), &bag, &null_pipe_plugins(), "test_field", crate::step::DispatchPhase::Pre).await; + assert!(matches!(result.outcome, FieldOutcome::Deny { .. })); + assert_eq!(result.taints, vec![TaintEvent { + label: "before".into(), + scopes: vec![TaintScope::Session], + }]); + } + + // ----- Plugin stage in pipe chain ----- + + /// Pipe-context plugin invoker that returns canned outcomes by name. + struct PipePlugin { + outcomes: std::collections::HashMap, + } + #[async_trait] + impl PluginInvoker for PipePlugin { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + self.outcomes + .get(name) + .cloned() + .ok_or_else(|| PluginError::NotFound(name.into())) + } + } + + #[tokio::test] + async fn pipeline_plugin_allow_continues() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { + outcomes: std::collections::HashMap::from([ + ("noop".to_string(), PluginOutcome::allow()), + ]), + }); + let p = make_pipeline(vec![ + Stage::Type(TypeCheck::Str), + Stage::Plugin { name: "noop".into() }, + Stage::Mask { keep_last: 4 }, + ]); + let result = evaluate_pipeline(&p, &json!("123-45-6789"), &bag, &plugins, "compensation", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789"))); + assert!(result.taints.is_empty()); + } + + #[tokio::test] + async fn pipeline_plugin_can_replace_value() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { + outcomes: std::collections::HashMap::from([ + ("scrubber".to_string(), PluginOutcome { + decision: Decision::Allow, + taints: vec![TaintEvent { + label: "PII".to_string(), + scopes: vec![TaintScope::Session], + }], + modified_value: Some(json!("***scrubbed***")), + }), + ]), + }); + let p = make_pipeline(vec![Stage::Plugin { name: "scrubber".into() }]); + let result = evaluate_pipeline(&p, &json!("sensitive data"), &bag, &plugins, "notes", crate::step::DispatchPhase::Pre).await; + assert_eq!(result.outcome, FieldOutcome::Replace(json!("***scrubbed***"))); + assert_eq!(result.taints, vec![TaintEvent { + label: "PII".into(), + scopes: vec![TaintScope::Session], + }]); + } + + #[tokio::test] + async fn pipeline_plugin_deny_halts() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { + outcomes: std::collections::HashMap::from([ + ("guard".to_string(), PluginOutcome { + decision: Decision::Deny { + reason: Some("policy violation".into()), + rule_source: "guard".into(), + }, + taints: vec![], + modified_value: None, + }), + ]), + }); + let p = make_pipeline(vec![ + Stage::Plugin { name: "guard".into() }, + // Should never run. + Stage::Mask { keep_last: 4 }, + ]); + let result = evaluate_pipeline(&p, &json!("data"), &bag, &plugins, "payload", crate::step::DispatchPhase::Pre).await; + match result.outcome { + FieldOutcome::Deny { reason, stage_index } => { + assert_eq!(reason, "policy violation"); + assert_eq!(stage_index, 0); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn pipeline_plugin_missing_fails_closed() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(PipePlugin { outcomes: Default::default() }); + let p = make_pipeline(vec![Stage::Plugin { name: "missing".into() }]); + let result = evaluate_pipeline(&p, &json!("data"), &bag, &plugins, "payload", crate::step::DispatchPhase::Pre).await; + match result.outcome { + FieldOutcome::Deny { reason, .. } => assert!(reason.contains("missing")), + other => panic!("expected Deny on missing plugin, got {:?}", other), + } + } + + // =================================================================== + // 5c additions: Exists, InSet, Always + // =================================================================== + + #[test] + fn exists_distinguishes_missing_from_falsy() { + let mut bag = AttributeBag::new(); + bag.set("args.flag", false); + // Key is present with a falsy value — IsTrue says false, Exists says true. + assert!(!eval_condition(&Condition::IsTrue { key: "args.flag".into() }, &bag)); + assert!(eval_condition(&Condition::Exists { key: "args.flag".into() }, &bag)); + // Missing key — Exists is false. + assert!(!eval_condition(&Condition::Exists { key: "args.nonexistent".into() }, &bag)); + } + + #[test] + fn in_set_member_and_non_member() { + let mut bag = AttributeBag::new(); + bag.set("subject.type", "user"); + bag.set( + "allowed_types", + std::collections::HashSet::from(["user".to_string(), "service".to_string()]), + ); + + assert!(eval_condition(&Condition::InSet { + value_key: "subject.type".into(), + set_key: "allowed_types".into(), + negate: false, + }, &bag)); + + bag.set("subject.type", "agent"); + assert!(!eval_condition(&Condition::InSet { + value_key: "subject.type".into(), + set_key: "allowed_types".into(), + negate: false, + }, &bag)); + } + + #[test] + fn in_set_negate() { + let mut bag = AttributeBag::new(); + bag.set("subject.type", "agent"); + bag.set( + "blocked_types", + std::collections::HashSet::from(["service".to_string()]), + ); + + // agent is not in blocked_types → not in → true + assert!(eval_condition(&Condition::InSet { + value_key: "subject.type".into(), + set_key: "blocked_types".into(), + negate: true, + }, &bag)); + } + + #[test] + fn in_set_missing_keys_resolve_to_false() { + let mut bag = AttributeBag::new(); + // Both missing → in = false → not in = true (spec §2.6 missing→false + // applies to the underlying `in` lookup; negate flips it). + assert!(!eval_condition(&Condition::InSet { + value_key: "x".into(), + set_key: "y".into(), + negate: false, + }, &bag)); + assert!(eval_condition(&Condition::InSet { + value_key: "x".into(), + set_key: "y".into(), + negate: true, + }, &bag)); + } + + #[test] + fn always_evaluates_true() { + let mut bag = AttributeBag::new(); + assert!(eval_expression(&Expression::Always, &bag)); + } + + #[test] + fn always_rule_unconditional_deny() { + let mut bag = AttributeBag::new(); + let r = Rule { + condition: Expression::Always, + effects: vec![Effect::Deny { reason: Some("unconditional".into()), code: None }], + source: "test".into(), + }; + match evaluate_rules(&[r], &bag) { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("unconditional")), + d => panic!("expected Deny, got {:?}", d), + } + } + + // =================================================================== + // 5c-v/vi: async step evaluator with mock resolvers + // =================================================================== + + use crate::step::{ + PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, + PluginError, PluginInvocation, PluginInvoker, PluginOutcome, + }; + use async_trait::async_trait; + + /// PDP resolver that returns the decision baked into it. Doesn't + /// inspect call.args — tests assert on call.dialect / on the decision + /// flow, not on Cedar/OPA-specific arg parsing. + struct FakePdp { + decision: Decision, + } + #[async_trait] + impl PdpResolver for FakePdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { decision: self.decision.clone(), diagnostics: vec![] }) + } + } + + /// PDP resolver that returns an error — exercises fail-closed path. + struct ErroringPdp; + #[async_trait] + impl PdpResolver for ErroringPdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Err(PdpError::Dispatch("simulated PDP outage".into())) + } + } + + /// Plugin invoker keyed by name → outcome. + struct FakePlugin { + decisions: std::collections::HashMap, + } + #[async_trait] + impl PluginInvoker for FakePlugin { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + match self.decisions.get(name) { + Some(d) => Ok(PluginOutcome { + decision: d.clone(), + taints: vec![], + modified_value: None, + }), + None => Err(PluginError::NotFound(name.into())), + } + } + } + + /// Null invoker — fails any plugin call (for PDP-only tests). + struct NullPlugins; + #[async_trait] + impl PluginInvoker for NullPlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + Err(PluginError::NotFound(name.into())) + } + } + + fn pdp_step(decision_diagnostic_label: &str) -> Effect { + Effect::Pdp { + call: PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::Value::String(decision_diagnostic_label.into()), + }, + on_deny: vec![], + on_allow: vec![], + } + } + + #[tokio::test] + async fn steps_rule_only_path() { + let mut bag = AttributeBag::new(); + let steps = vec![Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "test".into(), + }]; + let r = evaluate_effects(&steps, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await; + assert_eq!(r.decision, Decision::Allow); + } + + #[tokio::test] + async fn pdp_allow_continues() { + let mut bag = AttributeBag::new(); + let steps = vec![pdp_step("dummy")]; + let pdp: Arc = Arc::new(FakePdp { decision: Decision::Allow }); + assert_eq!( + evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision, + Decision::Allow, + ); + } + + #[tokio::test] + async fn pdp_deny_returns_deny() { + let mut bag = AttributeBag::new(); + let steps = vec![pdp_step("dummy")]; + let pdp: Arc = Arc::new(FakePdp { + decision: Decision::Deny { reason: Some("forbidden".into()), rule_source: "pdp".into() }, + }); + match evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("forbidden")), + d => panic!("expected Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn pdp_on_deny_reaction_can_override_reason() { + // PDP denies, on_deny reaction includes a more specific deny rule that + // fires before the PDP's deny is returned. + let mut bag = AttributeBag::new(); + let steps = vec![Effect::Pdp { + call: PdpCall { dialect: PdpDialect::Cedar, args: serde_yaml::Value::Null }, + on_deny: vec![Effect::When { + condition: Expression::Always, + body: vec![Effect::Deny { reason: Some("reaction took over".into()), code: None }], + source: "on_deny[0]".into(), + }], + on_allow: vec![], + }]; + let pdp: Arc = Arc::new(FakePdp { + decision: Decision::Deny { reason: Some("pdp original".into()), rule_source: "p".into() }, + }); + match evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, rule_source } => { + assert_eq!(reason.as_deref(), Some("reaction took over")); + assert_eq!(rule_source, "on_deny[0]"); + } + d => panic!("expected Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn pdp_on_allow_can_deny() { + // PDP allows, but an on_allow reaction can still deny (e.g., a + // taint check that fails). Outcome: deny. + let mut bag = AttributeBag::new(); + let steps = vec![Effect::Pdp { + call: PdpCall { dialect: PdpDialect::Cedar, args: serde_yaml::Value::Null }, + on_deny: vec![], + on_allow: vec![Effect::When { + condition: Expression::Always, + body: vec![Effect::Deny { reason: Some("reaction veto".into()), code: None }], + source: "on_allow[0]".into(), + }], + }]; + let pdp: Arc = Arc::new(FakePdp { decision: Decision::Allow }); + match evaluate_effects(&steps, &mut bag, &pdp, &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("reaction veto")), + d => panic!("expected Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn pdp_error_is_fail_closed() { + let mut bag = AttributeBag::new(); + let steps = vec![pdp_step("dummy")]; + match evaluate_effects(&steps, &mut bag, &(Arc::new(ErroringPdp) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, .. } => { + assert!(reason.unwrap().contains("PDP error")); + } + d => panic!("expected Deny on PDP error, got {:?}", d), + } + } + + #[tokio::test] + async fn plugin_allow_continues_deny_halts() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(FakePlugin { + decisions: std::collections::HashMap::from([ + ("ok_plugin".to_string(), Decision::Allow), + ("blocking_plugin".to_string(), Decision::Deny { + reason: Some("rate limit hit".into()), + rule_source: "plugin".into(), + }), + ]), + }); + + let allow_only = vec![Effect::Plugin { name: "ok_plugin".into() }]; + assert_eq!( + evaluate_effects(&allow_only, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &plugins, &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision, + Decision::Allow, + ); + + let with_deny = vec![ + Effect::Plugin { name: "ok_plugin".into() }, + Effect::Plugin { name: "blocking_plugin".into() }, + ]; + match evaluate_effects(&with_deny, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &plugins, &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("rate limit hit")), + d => panic!("expected Deny from blocking_plugin, got {:?}", d), + } + } + + #[tokio::test] + async fn plugin_error_is_fail_closed() { + let mut bag = AttributeBag::new(); + let plugins: std::sync::Arc = std::sync::Arc::new(FakePlugin { decisions: Default::default() }); + let steps = vec![Effect::Plugin { name: "missing".into() }]; + match evaluate_effects(&steps, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &plugins, &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { reason, rule_source } => { + assert!(reason.unwrap().contains("missing")); + assert!(rule_source.contains("missing")); + } + d => panic!("expected Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn taint_step_always_continues_and_accumulates() { + let mut bag = AttributeBag::new(); + let steps = vec![ + Effect::Taint { + label: "PII".into(), + scopes: vec![crate::pipeline::TaintScope::Session], + }, + // A later rule should still fire — taint doesn't short-circuit. + Effect::When { + condition: Expression::Always, + body: vec![Effect::Deny { reason: Some("after taint".into()), code: None }], + source: "p[1]".into(), + }, + ]; + let eval = evaluate_effects(&steps, &mut bag, &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), &null_plugins(), &noop_delegations(), crate::step::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await; + match eval.decision { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("after taint")), + d => panic!("expected Deny from rule after Taint, got {:?}", d), + } + // Step::Taint should have been accumulated into the phase's taints + // before the deny landed — audit needs to see what tainted before + // the policy halted. + assert_eq!(eval.taints.len(), 1); + assert_eq!(eval.taints[0].label, "PII"); + assert_eq!(eval.taints[0].scopes, vec![crate::pipeline::TaintScope::Session]); + } + + // ----- E2: FieldOp end-to-end through evaluate_steps ----- + + #[tokio::test] + async fn field_op_in_do_redacts_args_during_pre_phase() { + // Sketches the demo case: when condition holds, redact args.ssn + // — verifies the dispatcher walks effects, lifts the FieldOp + // out, and rewrites the payload. + let mut bag = AttributeBag::new(); + // Predicate is the rule's `when:`; here we make it always true. + let stages = vec![Stage::Redact { condition: None }]; + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::FieldOp { + path: "args.ssn".into(), + stages, + }], + source: "demo.policy[0]".into(), + }; + let steps = vec![Effect::from(rule)]; + let mut payload = crate::route::RoutePayload::new(json!({ + "ssn": "123-45-6789", + "name": "Jane", + })); + + let eval = evaluate_effects( + &steps, + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + assert_eq!(eval.decision, Decision::Allow); + assert!(eval.args_modified, "FieldOp should flag args_modified"); + // The ssn field should now read `[REDACTED]` (the stock value + // the Stage::Redact applier writes when no when-clause is set). + assert_eq!( + payload.args.get("ssn").and_then(|v| v.as_str()), + Some("[REDACTED]") + ); + // Other fields untouched. + assert_eq!(payload.args.get("name").and_then(|v| v.as_str()), Some("Jane")); + } + + #[tokio::test] + async fn field_op_targeting_result_in_pre_phase_is_skipped() { + // A `result.X | ...` op encountered during the Pre phase is a + // no-op — the result hasn't been produced yet. Same rule body + // can be reused across phases without branching. + let mut bag = AttributeBag::new(); + let stages = vec![Stage::Redact { condition: None }]; + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::FieldOp { + path: "result.ssn".into(), + stages, + }], + source: "demo.policy[0]".into(), + }; + let mut payload = crate::route::RoutePayload::new(json!({})); + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + assert_eq!(eval.decision, Decision::Allow); + assert!(!eval.args_modified); + assert!(!eval.result_modified); + } + + #[tokio::test] + async fn field_op_with_invalid_path_denies() { + // Path missing the `args.` / `result.` prefix is an author bug + // — fail closed with a clear violation rather than silently + // doing nothing. + let mut bag = AttributeBag::new(); + let stages = vec![Stage::Redact { condition: None }]; + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::FieldOp { + path: "ssn".into(), // missing prefix + stages, + }], + source: "demo.policy[0]".into(), + }; + let mut payload = crate::route::RoutePayload::new(json!({"ssn": "x"})); + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + match eval.decision { + Decision::Deny { reason, rule_source } => { + assert!(reason.unwrap_or_default().contains("must start with")); + assert_eq!(rule_source, "demo.policy[0]"); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + // ----- E3: Sequential / Parallel orchestration ----- + + #[tokio::test] + async fn sequential_runs_effects_in_order_until_deny() { + // A Sequential block runs each effect in order. Allow-only + // effects pass through; the first Deny halts the rest of the + // sequential body AND the parent step. + let mut bag = AttributeBag::new(); + let mut payload = crate::route::RoutePayload::new(json!({})); + + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::Sequential(vec![ + Effect::Allow, + Effect::Deny { + reason: Some("blocked by sequential".into()), + code: Some("seq.test".into()), + }, + Effect::Allow, // unreachable + ])], + source: "test.policy[0]".into(), + }; + + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + match eval.decision { + Decision::Deny { reason, rule_source } => { + assert_eq!(reason.as_deref(), Some("blocked by sequential")); + // The `code` override on the effect won — `seq.test` + // rather than the rule's `test.policy[0]` source. + assert_eq!(rule_source, "seq.test"); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn parallel_allows_when_no_branch_denies() { + // Both branches are no-op Allow → overall Continue → route Allow. + let mut bag = AttributeBag::new(); + let mut payload = crate::route::RoutePayload::new(json!({})); + + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::Parallel(vec![ + Effect::Allow, + Effect::Taint { + label: "audit_branch".into(), + scopes: vec![crate::pipeline::TaintScope::Session], + }, + ])], + source: "test.policy[0]".into(), + }; + + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + assert_eq!(eval.decision, Decision::Allow); + // Taints from parallel branches accumulate into the outer. + assert_eq!(eval.taints.len(), 1); + assert_eq!(eval.taints[0].label, "audit_branch"); + } + + #[tokio::test] + async fn parallel_denies_when_any_branch_denies() { + // One Allow, one Deny — overall Deny. + let mut bag = AttributeBag::new(); + let mut payload = crate::route::RoutePayload::new(json!({})); + + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::Parallel(vec![ + Effect::Allow, + Effect::Deny { + reason: Some("branch 1 denied".into()), + code: None, + }, + ])], + source: "test.policy[0]".into(), + }; + + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + match eval.decision { + Decision::Deny { reason, .. } => { + assert_eq!(reason.as_deref(), Some("branch 1 denied")); + } + other => panic!("expected Deny, got {:?}", other), + } + } + + #[tokio::test] + async fn parallel_picks_first_index_halt_not_first_to_complete() { + // When two branches both deny, the one with the lower index + // in the effects list wins — not the one that physically + // finishes first. + let mut bag = AttributeBag::new(); + let mut payload = crate::route::RoutePayload::new(json!({})); + + let rule = Rule { + condition: Expression::Always, + effects: vec![Effect::Parallel(vec![ + Effect::Deny { + reason: Some("idx-0".into()), + code: None, + }, + Effect::Deny { + reason: Some("idx-1".into()), + code: None, + }, + ])], + source: "test.policy[0]".into(), + }; + + let eval = evaluate_effects( + &vec![Effect::from(rule)], + &mut bag, + &(Arc::new(FakePdp { decision: Decision::Allow }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut payload, + ) + .await; + + match eval.decision { + Decision::Deny { reason, .. } => { + assert_eq!(reason.as_deref(), Some("idx-0"), "lower-index halt wins"); + } + other => panic!("expected Deny, got {:?}", other), + } + } +} diff --git a/crates/apl-core/src/lib.rs b/crates/apl-core/src/lib.rs new file mode 100644 index 00000000..46ee9647 --- /dev/null +++ b/crates/apl-core/src/lib.rs @@ -0,0 +1,45 @@ +// Location: ./crates/apl-core/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL core — Attribute Policy Language compiler + evaluator. +// +// This crate is the language nucleus. It does not depend on CPEX directly; +// the bridge from cpex-core extensions into the AttributeBag lives in +// `apl-cmf`, and the `PolicyEvaluator` implementation lives in `apl-cpex`. +// +// See docs/specs/apl-design.md for the full design. + +#![doc = "APL — Attribute Policy Language. See docs/specs/apl-design.md."] + +pub mod attributes; +pub mod evaluator; +pub mod parser; +pub mod pipeline; +pub mod plugin_decl; +pub mod route; +pub mod rules; +pub mod step; + +pub use attributes::{AttributeBag, AttributeExtractor, AttributeValue}; +pub use evaluator::{ + evaluate_pipeline, evaluate_rules, evaluate_effects, Decision, FieldOutcome, PipelineEvaluation, +}; +pub use parser::{ + compile_config, compile_policy_block_value, parse_pipeline, parse_predicate, parse_rule, + CompiledConfig, ConfigYaml, ParseError, RouteYaml, +}; +pub use pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintEvent, TaintScope, TypeCheck}; +pub use plugin_decl::{ + CapsView, EffectivePlugin, PluginDeclaration, PluginOverride, PluginRegistry, +}; +pub use route::{evaluate_post, evaluate_pre, evaluate_route, RouteDecision, RoutePayload}; +pub use rules::{ + CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Phase, PhaseSet, Rule, +}; +pub use step::{ + delegation_bag_keys, DelegateStep, DelegationError, DelegationInvoker, DelegationOutcome, + DispatchPhase, NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpFactory, + PdpResolver, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, +}; diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs new file mode 100644 index 00000000..2a41ff86 --- /dev/null +++ b/crates/apl-core/src/parser.rs @@ -0,0 +1,3929 @@ +// Location: ./crates/apl-core/src/parser.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL parser — DSL string → IR, and YAML config → HashMap. +// +// Runs once at config load. The IR it produces is what the evaluator walks +// at request time; the parser is never on the hot path. +// +// Grammar anchored in apl-dsl-spec.md §2 (predicates) / §3 (rules) / §8 (EBNF). +// YAML shape anchored in apl-design.md §5 (`routes:` as map keyed by route_key). +// +// Step 5a scope: +// ✓ Predicate grammar: identifiers, literals, comparisons, contains, +// & | ! parens, require(...) +// ✓ Actions: deny / allow / (default deny on missing) +// ✓ YAML top-level routes: keyed map, policy: / post_policy: blocks +// ✗ Steps (cedar:(), opa(), plugin(), taint()) — rejected with clear errors +// ✗ Pipe chains in args:/result: — fields parsed, values stashed as opaque +// ✗ `in` / `not in` / `exists()` — need IR variants first; rejected +// ✗ Multi-effect do: lists, sequential:/parallel: blocks — rejected + +use std::collections::HashMap; + +use serde::Deserialize; +use thiserror::Error; + +use crate::pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintScope, TypeCheck}; +use crate::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry}; +use crate::rules::{CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Rule}; +use crate::step::{DelegateStep, PdpCall, PdpDialect, Step}; + +// ===================================================================== +// Errors +// ===================================================================== + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("YAML parse error: {0}")] + Yaml(#[from] serde_yaml::Error), + + #[error("rule '{rule}': {msg}")] + Rule { rule: String, msg: String }, + + #[error("unsupported step `{kind}` in rule '{rule}' — defer to step 5b")] + UnsupportedStep { rule: String, kind: String }, + + #[error("predicate '{predicate}': {msg}")] + Predicate { predicate: String, msg: String }, +} + +// ===================================================================== +// Lexer +// ===================================================================== + +#[derive(Debug, Clone, PartialEq)] +enum Tok { + Ident(String), // dotted: subject.id, role.hr, authenticated + StringLit(String), + IntLit(i64), + FloatLit(f64), + BoolLit(bool), + Eq, // == + NotEq, // != + Gt, // > + GtEq, // >= + Lt, // < + LtEq, // <= + And, // & (must have surrounding spaces — caller enforces) + Or, // | + Not, // ! + LParen, + RParen, + Comma, + Contains, // keyword + Require, // keyword + Exists, // keyword + In, // keyword — set membership operator +} + +struct Lexer<'a> { + src: &'a str, + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Lexer<'a> { + fn new(src: &'a str) -> Self { + Self { src, bytes: src.as_bytes(), pos: 0 } + } + + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn bump(&mut self) -> Option { + let b = self.peek()?; + self.pos += 1; + Some(b) + } + + fn skip_ws(&mut self) { + while let Some(b) = self.peek() { + if b.is_ascii_whitespace() { self.pos += 1; } else { break; } + } + } + + fn tokenize_all(&mut self) -> Result, ParseError> { + let mut out = Vec::new(); + loop { + self.skip_ws(); + let Some(b) = self.peek() else { return Ok(out); }; + + let tok = match b { + b'(' => { self.pos += 1; Tok::LParen } + b')' => { self.pos += 1; Tok::RParen } + b',' => { self.pos += 1; Tok::Comma } + b'&' => { self.pos += 1; Tok::And } + b'|' => { self.pos += 1; Tok::Or } + b'=' => { + self.pos += 1; + if self.peek() == Some(b'=') { + self.pos += 1; Tok::Eq + } else { + return Err(self.err("expected `==`, saw `=`")); + } + } + b'!' => { + self.pos += 1; + if self.peek() == Some(b'=') { + self.pos += 1; Tok::NotEq + } else { + Tok::Not + } + } + b'>' => { + self.pos += 1; + if self.peek() == Some(b'=') { self.pos += 1; Tok::GtEq } else { Tok::Gt } + } + b'<' => { + self.pos += 1; + if self.peek() == Some(b'=') { self.pos += 1; Tok::LtEq } else { Tok::Lt } + } + b'"' | b'\'' => self.lex_string(b)?, + b'-' | b'0'..=b'9' => self.lex_number()?, + b if is_ident_start(b) => self.lex_ident_or_keyword(), + _ => return Err(self.err(&format!("unexpected char `{}`", b as char))), + }; + out.push(tok); + } + } + + fn lex_string(&mut self, quote: u8) -> Result { + self.bump(); // opening quote + let start = self.pos; + while let Some(b) = self.peek() { + if b == quote { break; } + self.pos += 1; + } + if self.peek() != Some(quote) { + return Err(self.err("unterminated string literal")); + } + let s = std::str::from_utf8(&self.bytes[start..self.pos]) + .map_err(|_| self.err("non-utf8 in string literal"))? + .to_string(); + self.bump(); // closing quote + Ok(Tok::StringLit(s)) + } + + fn lex_number(&mut self) -> Result { + let start = self.pos; + if self.peek() == Some(b'-') { self.pos += 1; } + while let Some(b) = self.peek() { + if b.is_ascii_digit() { self.pos += 1; } else { break; } + } + let mut is_float = false; + if self.peek() == Some(b'.') { + is_float = true; + self.pos += 1; + while let Some(b) = self.peek() { + if b.is_ascii_digit() { self.pos += 1; } else { break; } + } + } + let text = &self.src[start..self.pos]; + if is_float { + text.parse::().map(Tok::FloatLit) + .map_err(|_| self.err(&format!("bad float `{}`", text))) + } else { + text.parse::().map(Tok::IntLit) + .map_err(|_| self.err(&format!("bad int `{}`", text))) + } + } + + fn lex_ident_or_keyword(&mut self) -> Tok { + let start = self.pos; + while let Some(b) = self.peek() { + if is_ident_cont(b) { self.pos += 1; } else { break; } + } + let s = &self.src[start..self.pos]; + match s { + "true" => Tok::BoolLit(true), + "false" => Tok::BoolLit(false), + "contains" => Tok::Contains, + "require" => Tok::Require, + "exists" => Tok::Exists, + "in" => Tok::In, + // "not" is NOT a keyword — it only appears in the `not in` + // phrase. The parser handles that as an Ident("not") + Tok::In + // sequence in parse_identifier_predicate. + _ => Tok::Ident(s.to_string()), + } + } + + fn err(&self, msg: &str) -> ParseError { + ParseError::Predicate { + predicate: self.src.to_string(), + msg: format!("at byte {}: {}", self.pos, msg), + } + } +} + +fn is_ident_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' +} + +fn is_ident_cont(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'.' +} + +// ===================================================================== +// Predicate parser (Pratt-style; precedence () > ! > & > |) +// ===================================================================== + +struct PredParser<'a> { + src: &'a str, + toks: Vec, + pos: usize, +} + +impl<'a> PredParser<'a> { + fn parse(src: &'a str) -> Result { + let toks = Lexer::new(src).tokenize_all()?; + let mut p = Self { src, toks, pos: 0 }; + let expr = p.parse_or()?; + if p.pos < p.toks.len() { + return Err(p.err(&format!("trailing tokens after expression: {:?}", &p.toks[p.pos..]))); + } + Ok(expr) + } + + fn peek(&self) -> Option<&Tok> { self.toks.get(self.pos) } + fn bump(&mut self) -> Option { + let t = self.toks.get(self.pos).cloned()?; + self.pos += 1; + Some(t) + } + fn err(&self, msg: &str) -> ParseError { + ParseError::Predicate { + predicate: self.src.to_string(), + msg: msg.to_string(), + } + } + + fn parse_or(&mut self) -> Result { + let mut parts = vec![self.parse_and()?]; + while matches!(self.peek(), Some(Tok::Or)) { + self.bump(); + parts.push(self.parse_and()?); + } + Ok(if parts.len() == 1 { parts.pop().unwrap() } else { Expression::Or(parts) }) + } + + fn parse_and(&mut self) -> Result { + let mut parts = vec![self.parse_unary()?]; + while matches!(self.peek(), Some(Tok::And)) { + self.bump(); + parts.push(self.parse_unary()?); + } + Ok(if parts.len() == 1 { parts.pop().unwrap() } else { Expression::And(parts) }) + } + + fn parse_unary(&mut self) -> Result { + if matches!(self.peek(), Some(Tok::Not)) { + self.bump(); + let inner = self.parse_unary()?; + return Ok(Expression::Not(Box::new(inner))); + } + self.parse_atom() + } + + fn parse_atom(&mut self) -> Result { + match self.peek() { + Some(Tok::LParen) => { + self.bump(); + let inner = self.parse_or()?; + match self.bump() { + Some(Tok::RParen) => Ok(inner), + _ => Err(self.err("expected `)`")), + } + } + // `require(...)` is a rule-level shorthand per DSL §8 grammar + // (`rule = require_call | predicate ...`), not a sub-predicate. + // Trying to nest it inside `&` / `|` is a grammar error. + Some(Tok::Require) => Err(self.err( + "`require(...)` is a rule-level shorthand, not a sub-predicate \ + — use `&` / `|` / `!` over bare identifiers instead", + )), + Some(Tok::Exists) => self.parse_exists(), + Some(Tok::Ident(_)) => self.parse_identifier_predicate(), + other => Err(self.err(&format!("expected atom, got {:?}", other))), + } + } + + /// `exists()` — DSL §2.2. Returns true if the key is present + /// in the AttributeBag, regardless of value (distinct from truthiness). + fn parse_exists(&mut self) -> Result { + self.bump(); // exists + match self.bump() { + Some(Tok::LParen) => {} + _ => return Err(self.err("expected `(` after `exists`")), + } + let key = match self.bump() { + Some(Tok::Ident(s)) => s, + other => return Err(self.err(&format!( + "exists(...) expects an attribute key, got {:?}", other, + ))), + }; + match self.bump() { + Some(Tok::RParen) => {} + other => return Err(self.err(&format!( + "expected `)` after exists() argument, got {:?}", other, + ))), + } + Ok(Expression::Condition(Condition::Exists { key })) + } + + /// Parse a predicate that begins with an identifier: + /// - bare identifier: `authenticated` → IsTrue + /// - comparison: `delegation.depth > 2` + /// - contains: `session.labels contains "PII"` + /// - set membership: `subject.type in allowed_types` + /// - set non-membership: `subject.type not in blocked_types` + fn parse_identifier_predicate(&mut self) -> Result { + let key = match self.bump() { + Some(Tok::Ident(s)) => s, + _ => unreachable!("parse_atom dispatched here"), + }; + + // `in` and `not in` — two-key set membership (DSL §2.4). + if matches!(self.peek(), Some(Tok::In)) { + self.bump(); + return self.finish_in_set(key, false); + } + // `not in` shows up as Ident("not") + Tok::In. Treat that as a + // grammar phrase here; bare `not` outside this context is not a + // DSL keyword (use `!` for predicate negation). + if let Some(Tok::Ident(maybe_not)) = self.peek() { + if maybe_not == "not" { + let saved_pos = self.pos; + self.bump(); // consume "not" + if matches!(self.peek(), Some(Tok::In)) { + self.bump(); + return self.finish_in_set(key, true); + } + // Not "not in" — rewind so the downstream error reports + // the trailing-ident properly. + self.pos = saved_pos; + } + } + + let op = match self.peek() { + Some(Tok::Eq) => Some(CompareOp::Eq), + Some(Tok::NotEq) => Some(CompareOp::NotEq), + Some(Tok::Gt) => Some(CompareOp::Gt), + Some(Tok::GtEq) => Some(CompareOp::GtEq), + Some(Tok::Lt) => Some(CompareOp::Lt), + Some(Tok::LtEq) => Some(CompareOp::LtEq), + Some(Tok::Contains) => Some(CompareOp::Contains), + _ => None, + }; + + let Some(op) = op else { + // Bare identifier. + return Ok(Expression::Condition(Condition::IsTrue { key })); + }; + self.bump(); + + let value = match self.bump() { + Some(Tok::StringLit(s)) => Literal::String(s), + Some(Tok::IntLit(i)) => Literal::Int(i), + Some(Tok::FloatLit(f)) => Literal::Float(f), + Some(Tok::BoolLit(b)) => Literal::Bool(b), + Some(Tok::Ident(_)) => { + return Err(self.err( + "RHS-as-identifier on comparison operators not supported — \ + for set membership use `value_key in set_key`", + )); + } + other => return Err(self.err(&format!("expected literal RHS, got {:?}", other))), + }; + + Ok(Expression::Condition(Condition::Comparison { key, op, value })) + } + + fn finish_in_set(&mut self, value_key: String, negate: bool) -> Result { + let set_key = match self.bump() { + Some(Tok::Ident(s)) => s, + other => return Err(self.err(&format!( + "expected set-attribute identifier after `{}in`, got {:?}", + if negate { "not " } else { "" }, + other, + ))), + }; + Ok(Expression::Condition(Condition::InSet { value_key, set_key, negate })) + } +} + +/// Parse a predicate string into the IR. Public for tests + step-5b use. +pub fn parse_predicate(src: &str) -> Result { + PredParser::parse(src.trim()) +} + +// ===================================================================== +// Rule parser +// ===================================================================== + +/// Parse a single rule line into a `Rule`. +/// +/// Accepted forms (DSL §3.2): +/// 1. `"require(...)"` → rule-level shorthand, desugars to +/// `when: do: deny` +/// per DSL §8.1 +/// 2. `": "` → Rule { condition, action } +/// 3. `""` → Rule { condition, action: Deny } (default) +/// 4. `""` (action only) → treated as form 3 (always-true predicate) +/// +/// **Step kinds** (`plugin(...)`, `taint(...)`, `cedar:`, `opa(...)` etc.) +/// are handled by `parse_step`, not here. This function specifically parses +/// predicate-and-action rules; callers that don't know which they have +/// should use `parse_step` instead. +pub fn parse_rule(line: &str, source: &str) -> Result { + let trimmed = line.trim(); + + // require(...) shorthand — special-cased because it desugars to a + // negated predicate + Deny action, and the spec grammar (§8) puts it + // as a top-level rule alternative, not a sub-predicate. + if is_require_call(trimmed) { + let condition = parse_require_rule(trimmed)?; + return Ok(Rule::single( + condition, + Effect::Deny { reason: None, code: None }, + source, + )); + } + + // Step kinds shouldn't end up here. If they do, the caller used the + // wrong entry point — point them at parse_step. + if let Some(kind) = detect_step_kind(trimmed) { + return Err(ParseError::UnsupportedStep { + rule: trimmed.to_string(), + kind: format!("{} (use parse_step for step kinds)", kind), + }); + } + + let (predicate_str, effects) = match split_predicate_action(trimmed) { + Some((p, a)) => (p, parse_action(a, trimmed)?), + None => { + // No `:` — bare action (unconditional) or bare predicate (default deny). + if let Some(effects) = try_bare_action(trimmed) { + return Ok(Rule { + condition: Expression::Always, + effects, + source: source.to_string(), + }); + } + // DSL §2 default: bare predicate denies. + (trimmed, vec![Effect::Deny { reason: None, code: None }]) + } + }; + + let condition = parse_predicate(predicate_str) + .map_err(|e| ParseError::Rule { + rule: trimmed.to_string(), + msg: format!("{}", e), + })?; + + Ok(Rule { condition, effects, source: source.to_string() }) +} + +fn is_require_call(s: &str) -> bool { + s.trim_start().starts_with("require(") +} + +/// Parse `require(a)` / `require(a, b, ...)` / `require(a | b | ...)` and +/// return the desugared "when" expression per DSL §8.1: +/// +/// require(X) → IsFalse(X) +/// require(X, Y, ...) → Or([IsFalse(X), IsFalse(Y), ...]) (deny if any falsy) +/// require(X | Y | ...) → And([IsFalse(X), IsFalse(Y), ...]) (deny if all falsy) +/// +/// Caller wraps with `Effect::Deny`. +fn parse_require_rule(line: &str) -> Result { + let toks = Lexer::new(line).tokenize_all()?; + let mut iter = toks.into_iter().peekable(); + + let bad = |msg: &str| ParseError::Rule { + rule: line.to_string(), + msg: msg.to_string(), + }; + + match iter.next() { + Some(Tok::Require) => {} + _ => return Err(bad("expected `require`")), + } + match iter.next() { + Some(Tok::LParen) => {} + _ => return Err(bad("expected `(` after `require`")), + } + + let mut keys = Vec::new(); + let mut sep: Option = None; + + match iter.next() { + Some(Tok::Ident(s)) => keys.push(s), + _ => return Err(bad("expected identifier inside `require(...)`")), + } + + loop { + match iter.next() { + Some(Tok::RParen) => break, + Some(t @ Tok::Comma) | Some(t @ Tok::Or) => { + match &sep { + None => sep = Some(t), + Some(prev) if std::mem::discriminant(prev) == std::mem::discriminant(&t) => {} + _ => return Err(bad( + "require(...) cannot mix `,` (AND) and `|` (OR) — use one or the other", + )), + } + match iter.next() { + Some(Tok::Ident(s)) => keys.push(s), + _ => return Err(bad("expected identifier after `,` or `|` in require(...)")), + } + } + Some(other) => return Err(bad(&format!( + "expected `,`, `|`, or `)` in require(...), got {:?}", other, + ))), + None => return Err(bad("unexpected end of require(...) — missing `)`")), + } + } + + if iter.peek().is_some() { + return Err(bad("trailing tokens after `require(...)` — require is a complete rule")); + } + + let falses: Vec = keys + .into_iter() + .map(|k| Expression::Condition(Condition::IsFalse { key: k })) + .collect(); + if falses.len() == 1 { + return Ok(falses.into_iter().next().unwrap()); + } + Ok(match sep { + Some(Tok::Or) => Expression::And(falses), // require(X | Y) → !X & !Y + _ => Expression::Or(falses), // require(X, Y) → !X | !Y + }) +} + +/// Detect `taint(...)` / `plugin(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(`. +fn detect_step_kind(s: &str) -> Option<&'static str> { + let s = s.trim_start(); + for prefix in ["taint(", "plugin(", "cedar:", "cedarling:", "opa(", "authzen(", "nemo(", "sequential:", "parallel:"] { + if s.starts_with(prefix) { + return Some(prefix.trim_end_matches('(').trim_end_matches(':')); + } + } + None +} + +/// Split on the *last* unescaped `:` that's outside quotes and parens — this +/// is the predicate/action separator. The DSL doesn't escape colons, and `:` +/// doesn't appear in our predicate grammar, but quotes and parens can contain +/// arbitrary text. +fn split_predicate_action(s: &str) -> Option<(&str, &str)> { + let bytes = s.as_bytes(); + let mut depth: i32 = 0; + let mut in_quote: Option = None; + let mut last_colon: Option = None; + for (i, &b) in bytes.iter().enumerate() { + match (in_quote, b) { + (Some(q), c) if c == q => in_quote = None, + (Some(_), _) => {} + (None, b'"') | (None, b'\'') => in_quote = Some(b), + (None, b'(') => depth += 1, + (None, b')') => depth -= 1, + (None, b':') if depth == 0 => last_colon = Some(i), + _ => {} + } + } + last_colon.map(|i| (s[..i].trim(), s[i + 1..].trim())) +} + +/// Parse the *right* side of a shorthand `predicate: action` rule into a +/// single-element effects vec. Recognized forms (DSL §3 + the `code` +/// extension we added in E1): +/// +/// * `deny` → `vec![Effect::Deny { reason: None, code: None }]` +/// * `deny('reason')` → `vec![Effect::Deny { reason: Some, code: None }]` +/// * `deny('reason', 'code')` → `vec![Effect::Deny { reason: Some, code: Some }]` +/// * `allow` → `vec![Effect::Allow]` +/// +/// Anything else (plugin/delegate/taint) goes through `parse_step`, not +/// here — those are sibling Steps in v0. Multi-effect `do:` lists use a +/// separate parsing path that produces `Vec` directly. +fn parse_action(s: &str, rule: &str) -> Result, ParseError> { + if let Some(effect) = try_bare_action(s) { + return Ok(effect); + } + if let Some(deny) = try_parse_deny_call(s.trim(), rule)? { + return Ok(vec![deny]); + } + Err(ParseError::Rule { + rule: rule.to_string(), + msg: format!( + "unsupported action `{}` — recognized: `deny`, `deny('reason')`, `deny('reason', 'code')`, `allow`", + s.trim() + ), + }) +} + +fn try_bare_action(s: &str) -> Option> { + match s.trim() { + "deny" => Some(vec![Effect::Deny { reason: None, code: None }]), + "allow" => Some(vec![Effect::Allow]), + _ => None, + } +} + +/// Parse `deny('reason')` or `deny('reason', 'code')`. Returns +/// `Ok(None)` when `s` doesn't start with `deny(` so the caller can +/// fall through to other action handlers. +fn try_parse_deny_call(s: &str, rule: &str) -> Result, ParseError> { + if !s.starts_with("deny(") { + return Ok(None); + } + let inside = extract_call_args(s, "deny").ok_or_else(|| ParseError::Rule { + rule: rule.to_string(), + msg: "malformed `deny(...)`".into(), + })?; + // Two positional args max. Spec precedent: `deny('reason')` (1 arg); + // E1 extension: `deny('reason', 'code')` (2 args). Both quoted. + let parts = split_top_level_commas(&inside).map_err(|e| ParseError::Rule { + rule: rule.to_string(), + msg: format!("deny(...): {}", e), + })?; + let mut iter = parts.into_iter(); + let reason = match iter.next() { + Some(p) => Some(strip_string_literal(p.trim(), rule)?), + None => None, + }; + let code = match iter.next() { + Some(p) => Some(strip_string_literal(p.trim(), rule)?), + None => None, + }; + if iter.next().is_some() { + return Err(ParseError::Rule { + rule: rule.to_string(), + msg: "deny(...) takes at most two args: deny('reason', 'code')".into(), + }); + } + Ok(Some(Effect::Deny { reason, code })) +} + +/// Strip surrounding single or double quotes from a literal. The DSL +/// uses single quotes (`'reason'`) per the spec examples, but accept +/// double quotes too so YAML escaping is forgiving. +fn strip_string_literal(s: &str, rule: &str) -> Result { + let s = s.trim(); + if (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2) + || (s.starts_with('"') && s.ends_with('"') && s.len() >= 2) + { + Ok(s[1..s.len() - 1].to_string()) + } else { + Err(ParseError::Rule { + rule: rule.to_string(), + msg: format!("expected a quoted string, got `{}`", s), + }) + } +} + + +// ===================================================================== +// Step parser (policy: / post_policy: entries — supports steps + rules) +// ===================================================================== + +/// Parse a single YAML entry from a `policy:` / `post_policy:` list. +/// +/// Two YAML shapes (DSL §3.2 + §7): +/// - **String entry** — a rule line, taint effect, or plugin call. +/// - `"require(authenticated)"` → `Step::Rule` +/// - `"delegation.depth > 2: deny"` → `Step::Rule` +/// - `"plugin(rate_limiter)"` → `Step::Plugin` +/// - `"taint(PII, session)"` → `Step::Taint` +/// - **Map entry** (single-key map) — PDP call with optional reactions. +/// - `cedar: { action: read, resource: e, on_deny: [...] }` → `Step::Pdp` +/// - `opa("path"): { on_deny: [...] }` → `Step::Pdp` +pub fn parse_step(value: &serde_yaml::Value, source: &str) -> Result { + match value { + serde_yaml::Value::String(s) => parse_step_string(s, source), + serde_yaml::Value::Mapping(m) => parse_step_map(m, source), + other => Err(ParseError::Rule { + rule: format!("{:?}", other), + msg: "step must be a string or a single-key map".into(), + }), + } +} + +fn parse_step_string(line: &str, source: &str) -> Result { + let trimmed = line.trim(); + + // taint(...) — emit as Step::Taint, reusing the pipeline parser's logic + // so the shape stays consistent with field-level taint. + if trimmed.starts_with("taint(") { + let inside = extract_call_args(trimmed, "taint") + .ok_or_else(|| ParseError::Rule { + rule: trimmed.to_string(), + msg: "malformed `taint(...)`".into(), + })?; + let taint_stage = parse_taint(&inside, trimmed)?; + // parse_taint produces Stage::Taint; lift to Step::Taint. + if let Stage::Taint { label, scopes } = taint_stage { + return Ok(Step::Taint { label, scopes }); + } + unreachable!("parse_taint always returns Stage::Taint"); + } + + // plugin(name) — emit as Step::Plugin. + if trimmed.starts_with("plugin(") { + let inside = extract_call_args(trimmed, "plugin") + .ok_or_else(|| ParseError::Rule { + rule: trimmed.to_string(), + msg: "malformed `plugin(...)`".into(), + })?; + let name = inside.trim(); + if name.is_empty() { + return Err(ParseError::Rule { + rule: trimmed.to_string(), + msg: "plugin name must not be empty".into(), + }); + } + return Ok(Step::Plugin { name: name.to_string() }); + } + + // delegate(name, key: value, key: [a, b], ...) — emit as Step::Delegate. + // Compact alternative to the map form (`- delegate: { plugin: ..., ... }`). + // First positional arg is the plugin name; subsequent `key: value` + // pairs become per-call config overrides (or `on_error` if the key + // is reserved). Use the map form for nested configs the kwarg + // parser doesn't handle. + if trimmed.starts_with("delegate(") { + let inside = extract_call_args(trimmed, "delegate") + .ok_or_else(|| ParseError::Rule { + rule: trimmed.to_string(), + msg: "malformed `delegate(...)`".into(), + })?; + let parsed = parse_delegate_call_args(&inside, source)?; + return Ok(Step::Delegate(DelegateStep { + plugin_name: parsed.plugin_name, + config_override: parsed.config_override, + on_error: parsed.on_error, + source: source.to_string(), + })); + } + + // Otherwise fall through to the rule parser — predicate-and-action. + let rule = parse_rule(trimmed, source)?; + Ok(Step::Rule(rule)) +} + +/// Intermediate shape produced by [`parse_delegate_call_args`]. The +/// string-form parser fills this; the caller wraps into `Step::Delegate` +/// with the source path it has in scope. +struct ParsedDelegateCall { + plugin_name: String, + config_override: Option, + on_error: Option, +} + +/// Parse the inside-parens of `delegate(name, key: value, key: [a, b], ...)`. +/// +/// Grammar (informal): +/// ```text +/// delegate_args := plugin_name [, kwarg [, kwarg]*] +/// plugin_name := bare_ident_or_string +/// kwarg := key ":" value +/// value := scalar | "[" value (, value)* "]" +/// scalar := bare_word | number | "true" | "false" | quoted_string +/// ``` +/// +/// Reserved keys consumed before going into `config_override`: +/// - `on_error` — pulled out as `DelegateStep.on_error` +/// +/// Everything else lands in `config_override` as a yaml mapping. Use +/// the map form (`- delegate: { plugin: ..., config: { ... }, ... }`) +/// for nested config shapes the flat kwarg parser doesn't handle. +fn parse_delegate_call_args( + inside: &str, + source: &str, +) -> Result { + let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule { + rule: format!("delegate({inside})"), + msg: format!("{source}: {msg}"), + })?; + let mut parts_iter = parts.into_iter(); + + let plugin_name = parts_iter + .next() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ParseError::Rule { + rule: format!("delegate({inside})"), + msg: format!( + "{source}: `delegate(...)` requires a plugin name as the first \ + positional argument" + ), + })?; + // Strip wrapping quotes if the operator wrote `delegate("workday-oauth", ...)`. + let plugin_name = strip_wrapping_quotes(&plugin_name).to_string(); + if plugin_name.is_empty() { + return Err(ParseError::Rule { + rule: format!("delegate({inside})"), + msg: format!("{source}: `delegate(...)` plugin name cannot be empty"), + }); + } + + let mut on_error: Option = None; + let mut config_map = serde_yaml::Mapping::new(); + + for raw_kwarg in parts_iter { + let kwarg = raw_kwarg.trim(); + if kwarg.is_empty() { + continue; + } + let (key, value_str) = kwarg + .split_once(':') + .ok_or_else(|| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!( + "{source}: `delegate(...)` kwarg `{kwarg}` must be `key: value` \ + (use the map form for richer config)" + ), + })?; + let key = key.trim(); + let value_str = value_str.trim(); + if key.is_empty() { + return Err(ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: `delegate(...)` kwarg has empty key"), + }); + } + if key == "on_error" { + let val = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: on_error: {msg}"), + })?; + on_error = Some( + val.as_str() + .ok_or_else(|| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: `on_error` must be a string"), + })? + .to_string(), + ); + continue; + } + // Reject `plugin:` as a kwarg — the plugin name is the positional + // first argument; allowing both would be ambiguous. + if key == "plugin" { + return Err(ParseError::Rule { + rule: kwarg.to_string(), + msg: format!( + "{source}: `plugin` is set as the first positional argument \ + of `delegate(...)`; don't pass it as a kwarg too" + ), + }); + } + let value = + parse_delegate_value(value_str).map_err(|msg| ParseError::Rule { + rule: kwarg.to_string(), + msg: format!("{source}: `{key}`: {msg}"), + })?; + config_map.insert(serde_yaml::Value::String(key.to_string()), value); + } + + let config_override = if config_map.is_empty() { + None + } else { + Some(serde_yaml::Value::Mapping(config_map)) + }; + + Ok(ParsedDelegateCall { + plugin_name, + config_override, + on_error, + }) +} + +/// Split a `key: value, key: value` string on TOP-LEVEL commas only — +/// commas inside `[...]` or quoted strings are preserved as part of +/// the surrounding value. Returns the comma-separated pieces (trimmed +/// at boundaries; whitespace inside values preserved). +/// +/// Errors on unmatched brackets / unterminated quotes — those produce +/// confusing downstream errors otherwise. +fn split_top_level_commas(input: &str) -> Result, String> { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut bracket_depth: usize = 0; + let mut quote: Option = None; + let mut escape = false; + + for ch in input.chars() { + if escape { + current.push(ch); + escape = false; + continue; + } + if let Some(q) = quote { + current.push(ch); + if ch == '\\' { + escape = true; + } else if ch == q { + quote = None; + } + continue; + } + match ch { + '"' | '\'' => { + quote = Some(ch); + current.push(ch); + } + '[' | '(' | '{' => { + bracket_depth += 1; + current.push(ch); + } + ']' | ')' | '}' => { + bracket_depth = bracket_depth.checked_sub(1).ok_or_else(|| { + format!("unmatched `{ch}` in delegate(...) args") + })?; + current.push(ch); + } + ',' if bracket_depth == 0 => { + parts.push(std::mem::take(&mut current)); + } + _ => current.push(ch), + } + } + if quote.is_some() { + return Err("unterminated quoted string in delegate(...) args".to_string()); + } + if bracket_depth != 0 { + return Err("unbalanced brackets in delegate(...) args".to_string()); + } + parts.push(current); + Ok(parts) +} + +/// Parse a single value from the function-call form: a scalar +/// (string / number / bool) or a list literal `[a, b, c]`. Use the +/// map form for anything more complex. +fn parse_delegate_value(s: &str) -> Result { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err("empty value".to_string()); + } + // List literal — recursive scalar parse on each element. + if let Some(stripped) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) + { + let items = split_top_level_commas(stripped)?; + let mut out = Vec::with_capacity(items.len()); + for item in items { + let item = item.trim(); + if item.is_empty() { + continue; + } + out.push(parse_delegate_value(item)?); + } + return Ok(serde_yaml::Value::Sequence(out)); + } + // Quoted string — strip the surrounding quotes. + if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2) + { + return Ok(serde_yaml::Value::String( + trimmed[1..trimmed.len() - 1].to_string(), + )); + } + // Bool literals. + if trimmed == "true" { + return Ok(serde_yaml::Value::Bool(true)); + } + if trimmed == "false" { + return Ok(serde_yaml::Value::Bool(false)); + } + // Numeric literals — integer first, then float. + if let Ok(n) = trimmed.parse::() { + return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(n))); + } + if let Ok(f) = trimmed.parse::() { + return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(f))); + } + // Fallback: treat as bare string (e.g. `target: workday-api` → + // value is `workday-api`). Same convention as YAML scalars. + Ok(serde_yaml::Value::String(trimmed.to_string())) +} + +/// Strip a single pair of wrapping `"`/`'` if present. No-op on +/// unquoted input. Used for the positional plugin name where the +/// operator may have quoted to escape a hyphen or similar (`delegate("workday-oauth")`). +fn strip_wrapping_quotes(s: &str) -> &str { + let bytes = s.as_bytes(); + if bytes.len() >= 2 { + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + return &s[1..s.len() - 1]; + } + } + s +} + +fn parse_step_map( + m: &serde_yaml::Mapping, + source: &str, +) -> Result { + // Canonical structured rule: `- when: X\n do: Y` (DSL §3.2). + // Detected by the presence of *both* `when` and `do` keys — order + // doesn't matter, and the map can carry extra keys for future + // extensions (e.g. `id:` for rule identifiers). + if has_key(m, "when") && has_key(m, "do") { + return parse_when_do_rule(m, source); + } + + if m.len() != 1 { + return Err(ParseError::Rule { + rule: format!("{:?}", m), + msg: "step map must have exactly one key (PDP call signature, \ + `when:`/`do:`, or a `predicate: [effects...]` shorthand)" + .into(), + }); + } + let (key_val, body_val) = m.iter().next().unwrap(); + let key = key_val.as_str().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", key_val), + msg: "PDP step key must be a string".into(), + })?; + + // Shorthand multi-effect map: `- "predicate": [list]` (DSL §3.1 + // multi-effect from one predicate). Detected by a single-key map + // whose value is a YAML sequence. Single-effect map shorthand + // (`- "predicate": deny`) still goes through `parse_step_string` + // via the colon-split, NOT here — by the time we land in this + // function, single-string values have already been resolved by + // the caller's `parse_step` dispatch. + if let serde_yaml::Value::Sequence(items) = body_val { + // Skip PDP keys — `cedar:` / `opa:` etc. have list bodies for + // `on_deny:` / `on_allow:` and need the existing handling. + // Also skip `sequential:` / `parallel:` orchestration keys + // since they take a list body and would otherwise be parsed + // as predicates. The shorthand recognises only predicate- + // shaped keys. + let trimmed = key.trim(); + if trimmed != "delegate" + && trimmed != "sequential" + && trimmed != "parallel" + && !is_known_pdp_dialect(trimmed) + { + return parse_shorthand_multi_effect(trimmed, items, source); + } + } + + // `delegate:` is a special non-PDP step shape — branch before the + // dialect logic. See `parse_delegate_step` for the expected body. + if key.trim() == "delegate" { + return parse_delegate_step(body_val, source); + } + + // E3: top-level `sequential:` / `parallel:` orchestration — + // wrap the resulting Effect into an unconditional Rule so the + // top-level Vec stays uniform. + match key.trim() { + "sequential" => { + let effect = parse_sequential_effect(body_val, source)?; + return Ok(Step::Rule(Rule { + condition: Expression::Always, + effects: vec![effect], + source: source.to_string(), + })); + } + "parallel" => { + let effect = parse_parallel_effect(body_val, source)?; + return Ok(Step::Rule(Rule { + condition: Expression::Always, + effects: vec![effect], + source: source.to_string(), + })); + } + _ => {} + } + + // Split the key into "dialect" + optional "(args)" portion. + let (dialect_str, paren_args) = if let Some(open) = key.find('(') { + let close = key.rfind(')').ok_or_else(|| ParseError::Rule { + rule: key.to_string(), + msg: "missing `)` in PDP call signature".into(), + })?; + let inside = key[open + 1..close].trim().to_string(); + (key[..open].trim(), Some(inside)) + } else { + (key.trim(), None) + }; + + let dialect = PdpDialect::from_key(dialect_str); + + // Extract args + on_deny/on_allow. + // Cedar: body map carries args fields directly + on_deny/on_allow. + // Others: paren_args carries the call signature; body map is reactions only. + let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", body_val), + msg: format!("`{}:` body must be a map (with on_deny / on_allow / args)", key), + })?; + + let (args, on_deny, on_allow) = extract_pdp_body(body, paren_args.as_deref(), source)?; + + Ok(Step::Pdp { + call: PdpCall { dialect, args }, + on_deny, + on_allow, + }) +} + +/// Parse a `delegate:` step body into a `Step::Delegate`. Accepted +/// YAML shape: +/// +/// ```yaml +/// - delegate: +/// plugin: workday-oauth # required — TokenDelegateHook plugin name +/// config: # optional — per-call config override +/// target: workday-api +/// permissions: [read_compensation] +/// on_error: deny # optional — deny | continue (default deny) +/// ``` +/// +// ===================================================================== +// Effect / when-do parsing (E1) +// ===================================================================== + +/// Lookup helper — `serde_yaml::Mapping::contains_key` only matches when +/// the search key is a `Value`, so we wrap the string conversion. +fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool { + m.contains_key(serde_yaml::Value::String(key.to_string())) +} + +/// Whether a top-level map key is a recognized PDP dialect. Used by +/// the shorthand-list detector to avoid mis-parsing a `cedar: [...]` +/// reaction list as a predicate-with-effects map. +fn is_known_pdp_dialect(key: &str) -> bool { + let base = key.find('(').map(|i| &key[..i]).unwrap_or(key); + matches!( + base.trim(), + "cedar" | "cedarling" | "opa" | "authzen" | "nemo" + ) +} + +/// Parse the canonical `- when: X` `do: Y` rule form (DSL §3.2). `Y` +/// may be a single effect string (`do: deny`) or a list of effect +/// entries (`do: [plugin(audit), taint(X), deny('msg')]`). Map-form +/// effects (like a nested `delegate:` block) are allowed inside `do:` +/// via the same dispatch as top-level steps. +fn parse_when_do_rule( + m: &serde_yaml::Mapping, + source: &str, +) -> Result { + // Validate keys — surface a useful error if there's stray content + // beyond `when:` / `do:` (e.g. typo'd `whens:`). `id:` is reserved + // for a future rule-identifier extension; tolerate it as a + // pass-through for now. + for (k, _) in m.iter() { + let key = k.as_str().unwrap_or(""); + if !matches!(key, "when" | "do" | "id") { + return Err(ParseError::Rule { + rule: format!("{:?}", m), + msg: format!( + "unexpected key `{}` in when/do rule (allowed: `when`, `do`, `id`)", + key + ), + }); + } + } + + let when_val = m + .get(serde_yaml::Value::String("when".into())) + .expect("has_key verified above"); + let predicate = when_val.as_str().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", when_val), + msg: "`when:` must be a predicate string".into(), + })?; + let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule { + rule: format!("when: {}", predicate), + msg: format!("{}", e), + })?; + + let do_val = m + .get(serde_yaml::Value::String("do".into())) + .expect("has_key verified above"); + let effects = parse_do_body(do_val, source)?; + if effects.is_empty() { + return Err(ParseError::Rule { + rule: format!("{:?}", m), + msg: "`do:` produced no effects".into(), + }); + } + + Ok(Step::Rule(Rule { + condition, + effects, + source: source.to_string(), + })) +} + +/// Parse the shorthand multi-effect map form: `- "predicate": [list]` +/// (DSL §3 example at line 386). Equivalent to the canonical +/// `when: predicate` `do: [list]` shape, just terser. +fn parse_shorthand_multi_effect( + predicate: &str, + effect_list: &[serde_yaml::Value], + source: &str, +) -> Result { + let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule { + rule: predicate.to_string(), + msg: format!("{}", e), + })?; + + let mut effects = Vec::with_capacity(effect_list.len()); + for item in effect_list { + effects.push(parse_effect_value(item, source)?); + } + if effects.is_empty() { + return Err(ParseError::Rule { + rule: predicate.to_string(), + msg: "shorthand multi-effect map produced no effects".into(), + }); + } + Ok(Step::Rule(Rule { + condition, + effects, + source: source.to_string(), + })) +} + +/// Parse a `do:` body — single effect string, list of effects, or a +/// single map-shaped effect (`do: { parallel: [...] }`, +/// `do: { delegate: {...} }`, etc.). +fn parse_do_body( + val: &serde_yaml::Value, + source: &str, +) -> Result, ParseError> { + match val { + serde_yaml::Value::String(s) => Ok(vec![parse_effect_string(s, source)?]), + serde_yaml::Value::Sequence(items) => items + .iter() + .map(|item| parse_effect_value(item, source)) + .collect(), + serde_yaml::Value::Mapping(_) => { + // Single map-form effect — delegate, sequential, parallel. + // Route through parse_effect_value which dispatches by key. + Ok(vec![parse_effect_value(val, source)?]) + } + other => Err(ParseError::Rule { + rule: format!("{:?}", other), + msg: "`do:` value must be a string, a list of effects, or an effect map".into(), + }), + } +} + +/// Parse one effect entry from a YAML value — string form or map form +/// (the latter for `delegate:` configs nested inside `do:`, +/// `sequential:`, and `parallel:`). +fn parse_effect_value( + val: &serde_yaml::Value, + source: &str, +) -> Result { + match val { + serde_yaml::Value::String(s) => parse_effect_string(s, source), + serde_yaml::Value::Mapping(m) => { + // E3: `sequential:` / `parallel:` map forms — a single-key + // map whose key is `sequential` / `parallel` and whose + // value is a list of effects. + if m.len() == 1 { + let (k, v) = m.iter().next().unwrap(); + if let Some(key_str) = k.as_str() { + match key_str.trim() { + "sequential" => return parse_sequential_effect(v, source), + "parallel" => return parse_parallel_effect(v, source), + _ => {} + } + } + } + // Otherwise reuse the existing step-map parser for + // `delegate:`, `cedar:` etc. and collapse the Step. + let step = parse_step(val, source)?; + step_to_effect(step, source) + } + other => Err(ParseError::Rule { + rule: format!("{:?}", other), + msg: "effect entry must be a string or a map".into(), + }), + } +} + +/// Parse a `sequential: [list]` effect value. The body MUST be a list +/// (a single effect would defeat the purpose of explicit grouping). +fn parse_sequential_effect( + body: &serde_yaml::Value, + source: &str, +) -> Result { + let items = body.as_sequence().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", body), + msg: "`sequential:` body must be a list of effects".into(), + })?; + if items.is_empty() { + return Err(ParseError::Rule { + rule: format!("{:?}", body), + msg: "`sequential:` body is empty".into(), + }); + } + let mut effects = Vec::with_capacity(items.len()); + for item in items { + effects.push(parse_effect_value(item, source)?); + } + Ok(Effect::Sequential(effects)) +} + +/// Parse a `parallel: [list]` effect value. The body MUST be a list, +/// and the parsed Effect is validated for parallel-purity (rejects +/// `FieldOp` / `Delegate` nested anywhere underneath). +fn parse_parallel_effect( + body: &serde_yaml::Value, + source: &str, +) -> Result { + let items = body.as_sequence().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", body), + msg: "`parallel:` body must be a list of effects".into(), + })?; + if items.is_empty() { + return Err(ParseError::Rule { + rule: format!("{:?}", body), + msg: "`parallel:` body is empty".into(), + }); + } + let mut effects = Vec::with_capacity(items.len()); + for item in items { + effects.push(parse_effect_value(item, source)?); + } + let parallel = Effect::Parallel(effects); + parallel + .validate_parallel_purity() + .map_err(|msg| ParseError::Rule { + rule: source.to_string(), + msg, + })?; + Ok(parallel) +} + +/// Parse one effect string. Reuses [`parse_step_string`] for forms +/// shared with top-level steps (`plugin(...)`, `taint(...)`, +/// `delegate(...)`, predicate-action rules), then collapses the +/// resulting Step into an Effect. +fn parse_effect_string(s: &str, source: &str) -> Result { + // Bare `allow` / `deny` / `deny('reason')` / `deny('reason', 'code')` + // are accepted directly — they map to control effects with no + // associated condition. Same parsing as the right-hand side of a + // shorthand `predicate: action` rule. + let trimmed = s.trim(); + if let Some(mut effects) = try_bare_action(trimmed) { + if effects.len() == 1 { + return Ok(effects.pop().unwrap()); + } + } + if let Some(effect) = try_parse_deny_call(trimmed, s)? { + return Ok(effect); + } + // Content effect — `result.salary | redact`, `args.ssn | mask(4)`, + // etc. Detected by a top-level `|` that splits a dotted path from + // a pipe chain. The pipe is at top level (depth 0); commas / + // parens inside the chain don't get confused. + if let Some(field_op) = try_parse_field_op(trimmed, s)? { + return Ok(field_op); + } + // Everything else (plugin/delegate/taint/rule) routes through the + // step parser; collapse the result. + let step = parse_step_string(s, source)?; + step_to_effect(step, source) +} + +/// Parse ` | [| ...]` into an `Effect::FieldOp`. +/// Returns `Ok(None)` when no top-level `|` is found so the caller can +/// fall through to other effect handlers. +fn try_parse_field_op(s: &str, rule: &str) -> Result, ParseError> { + let Some(pipe_idx) = find_top_level_pipe(s) else { + return Ok(None); + }; + let path = s[..pipe_idx].trim(); + let chain = s[pipe_idx + 1..].trim(); + if path.is_empty() || chain.is_empty() { + return Ok(None); + } + // The path must look like a dotted field reference. Anything else + // (e.g. `role.hr | role.security` — though that wouldn't get here + // because predicates don't appear in effect position) is a sign + // the author meant something other than a field op. + if !is_valid_field_path(path) { + return Ok(None); + } + let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule { + rule: rule.to_string(), + msg: format!("field op `{}`: {}", path, e), + })?; + if pipeline.stages.is_empty() { + return Err(ParseError::Rule { + rule: rule.to_string(), + msg: format!("field op `{}` has no stages", path), + }); + } + Ok(Some(Effect::FieldOp { + path: path.to_string(), + stages: pipeline.stages, + })) +} + +/// Find the byte index of the first top-level `|` that isn't part of +/// `||` (logical-or inside a predicate). Depth-aware: skips `|` inside +/// `(...)` / `[...]` and inside single- or double-quoted strings. +fn find_top_level_pipe(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut depth: i32 = 0; + let mut quote: Option = None; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if let Some(q) = quote { + if b == b'\\' { + i += 2; + continue; + } + if b == q { + quote = None; + } + i += 1; + continue; + } + match b { + b'\'' | b'"' => quote = Some(b), + b'(' | b'[' => depth += 1, + b')' | b']' => depth -= 1, + b'|' if depth == 0 => { + // Skip `||` — never appears in effect strings today + // but defend against it anyway. + if bytes.get(i + 1) == Some(&b'|') { + i += 2; + continue; + } + return Some(i); + } + _ => {} + } + i += 1; + } + None +} + +/// A field path is a dotted identifier sequence rooted at `args.` or +/// `result.`. Reject anything else early so a stray `role.hr | …` in +/// effect position fails fast. +fn is_valid_field_path(s: &str) -> bool { + let Some(rest) = s.strip_prefix("args.").or_else(|| s.strip_prefix("result.")) else { + return false; + }; + !rest.is_empty() + && rest + .split('.') + .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_alphanumeric() || c == '_')) +} + +/// Collapse a `Step` produced by the legacy step parser into an +/// `Effect`. The legitimate inputs are `Plugin`, `Delegate`, `Taint`, +/// and `Rule` (when a control action like `deny`/`allow` was parsed). +/// Anything else (`Pdp`) is rejected — nested PDP calls inside `do:` +/// are out of scope for E1. +/// Recursively map a top-level `Step` (as produced by `parse_step`) into +/// an `Effect`. Used at compile_apl_blocks during E4 — keeps `parse_step`'s +/// internal shape for the moment while the public IR collapses to Effect. +/// All five Step variants map cleanly: Rule → When, Pdp → Pdp (recursive +/// on reactions), Plugin/Delegate/Taint pass-through. +pub(crate) fn step_to_top_level_effect(step: Step) -> Result { + match step { + Step::Rule(rule) => Ok(Effect::When { + condition: rule.condition, + body: rule.effects, + source: rule.source, + }), + Step::Pdp { call, on_allow, on_deny } => { + let on_allow = on_allow + .into_iter() + .map(step_to_top_level_effect) + .collect::, _>>()?; + let on_deny = on_deny + .into_iter() + .map(step_to_top_level_effect) + .collect::, _>>()?; + Ok(Effect::Pdp { call, on_allow, on_deny }) + } + Step::Plugin { name } => Ok(Effect::Plugin { name }), + Step::Delegate(d) => Ok(Effect::Delegate(d)), + Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), + } +} + +fn step_to_effect(step: Step, source: &str) -> Result { + match step { + Step::Plugin { name } => Ok(Effect::Plugin { name }), + Step::Delegate(d) => Ok(Effect::Delegate(d)), + Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), + Step::Rule(rule) => { + // Nested when/do inside a do: list isn't supported in E1 + // — only control effects (allow/deny) flatten cleanly. + if !matches!(rule.condition, Expression::Always) { + return Err(ParseError::Rule { + rule: source.to_string(), + msg: "conditional rules nested inside `do:` are not supported in E1 \ + (use a sibling `when:`/`do:` rule instead)" + .into(), + }); + } + if rule.effects.len() != 1 { + return Err(ParseError::Rule { + rule: source.to_string(), + msg: format!( + "unconditional rule inside `do:` must produce exactly one \ + effect, got {}", + rule.effects.len() + ), + }); + } + Ok(rule.effects.into_iter().next().unwrap()) + } + Step::Pdp { .. } => Err(ParseError::Rule { + rule: source.to_string(), + msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \ + step instead)" + .into(), + }), + } +} + +/// `config:` is opaque — the framework hands it to the named plugin +/// via the existing per-call config-override pathway. The plugin +/// owns the typed schema (target / audience / permissions / mode / +/// attenuation are conventions, not parser-enforced). +fn parse_delegate_step( + body_val: &serde_yaml::Value, + source: &str, +) -> Result { + let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`delegate:` body must be a map with `plugin:` and optional \ + `config:` / `on_error:`" + .to_string(), + })?; + + let plugin = body + .get(serde_yaml::Value::String("plugin".to_string())) + .ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`delegate:` requires `plugin: ` referencing a \ + top-level plugin registered under `token.delegate`" + .to_string(), + })?; + let plugin_name = plugin + .as_str() + .ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`delegate.plugin` must be a string".to_string(), + })? + .to_string(); + if plugin_name.is_empty() { + return Err(ParseError::Rule { + rule: source.to_string(), + msg: "`delegate.plugin` cannot be empty".to_string(), + }); + } + + let config_override = body + .get(serde_yaml::Value::String("config".to_string())) + .cloned(); + + let on_error = match body.get(serde_yaml::Value::String("on_error".to_string())) { + Some(v) => Some( + v.as_str() + .ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`delegate.on_error` must be a string (e.g. `deny`, \ + `continue`)" + .to_string(), + })? + .to_string(), + ), + None => None, + }; + + Ok(Step::Delegate(DelegateStep { + plugin_name, + config_override, + on_error, + source: source.to_string(), + })) +} + +/// Split a PDP body into (args, on_deny, on_allow). +/// +/// If `paren_args` is `Some`, the call's args are the string inside the +/// parens (OPA-style) and the body map only carries reactions. If `None`, +/// the body map carries both args and reactions (Cedar-style); we strip +/// the reaction keys and treat what's left as args. +fn extract_pdp_body( + body: &serde_yaml::Mapping, + paren_args: Option<&str>, + source: &str, +) -> Result<(serde_yaml::Value, Vec, Vec), ParseError> { + let mut on_deny = Vec::new(); + let mut on_allow = Vec::new(); + let mut args_map = serde_yaml::Mapping::new(); + + for (k, v) in body { + match k.as_str() { + Some("on_deny") => { + on_deny = parse_reaction_list(v, source, "on_deny")?; + } + Some("on_allow") => { + on_allow = parse_reaction_list(v, source, "on_allow")?; + } + _ => { + // Non-reaction key — part of args (Cedar-style). + args_map.insert(k.clone(), v.clone()); + } + } + } + + let args = match paren_args { + Some(s) => serde_yaml::Value::String(s.to_string()), + None => serde_yaml::Value::Mapping(args_map), + }; + + Ok((args, on_deny, on_allow)) +} + +fn parse_reaction_list( + v: &serde_yaml::Value, + source: &str, + which: &str, +) -> Result, ParseError> { + let list = v.as_sequence().ok_or_else(|| ParseError::Rule { + rule: format!("{:?}", v), + msg: format!("`{}:` must be a list of steps", which), + })?; + list.iter() + .enumerate() + .map(|(i, entry)| parse_step(entry, &format!("{}.{}[{}]", source, which, i))) + .collect() +} + +/// Extract the args inside a call like `taint(X, Y)` or `plugin(foo)`. +/// Returns the substring between the outermost matching parens. +fn extract_call_args(line: &str, name: &str) -> Option { + let line = line.trim(); + if !line.starts_with(name) { + return None; + } + let after = &line[name.len()..]; + if !after.starts_with('(') { + return None; + } + // Find the matching close paren. + let bytes = after.as_bytes(); + let mut depth = 0; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + // Anything after the close paren is invalid. + if after[i + 1..].trim().is_empty() { + return Some(after[1..i].to_string()); + } + return None; + } + } + _ => {} + } + } + None +} + +// ===================================================================== +// Pipe-chain parser (args: / result: field pipelines) +// ===================================================================== + +/// Parse a pipe-chain string into a `Pipeline`. +/// +/// Splits on `|` (outside parens/quotes), trims each stage, parses each. +/// Empty pipelines (empty string or whitespace) are valid — they produce +/// `Pipeline { stages: vec![] }`. +pub fn parse_pipeline(src: &str) -> Result { + let mut pipeline = Pipeline::new(); + for seg in split_top_level(src.trim(), b'|') { + let seg = seg.trim(); + if seg.is_empty() { + continue; + } + pipeline.push(parse_stage(seg)?); + } + Ok(pipeline) +} + +/// Split `s` on `delim` at depth 0 — respects parens and quotes. +fn split_top_level(s: &str, delim: u8) -> Vec<&str> { + let bytes = s.as_bytes(); + let mut out = Vec::new(); + let mut depth: i32 = 0; + let mut in_quote: Option = None; + let mut start = 0; + for (i, &b) in bytes.iter().enumerate() { + match (in_quote, b) { + (Some(q), c) if c == q => in_quote = None, + (Some(_), _) => {} + (None, b'"') | (None, b'\'') => in_quote = Some(b), + (None, b'(') | (None, b'[') => depth += 1, + (None, b')') | (None, b']') => depth -= 1, + (None, c) if c == delim && depth == 0 => { + out.push(&s[start..i]); + start = i + 1; + } + _ => {} + } + } + out.push(&s[start..]); + out +} + +fn parse_stage(src: &str) -> Result { + let s = src.trim(); + let bad = |msg: &str| ParseError::Predicate { + predicate: src.to_string(), + msg: msg.to_string(), + }; + + // Bare range literal: starts with `-`, digit, or `..`. + if let Some(stage) = try_parse_range(s) { + return Ok(stage); + } + + // Otherwise the stage starts with an identifier (keyword) optionally + // followed by `(args)`. + let (head, args) = split_head_args(s) + .ok_or_else(|| bad("expected stage identifier"))?; + + match (head, args.as_deref()) { + // ----- Bare validators / transforms / effects ----- + ("str", None) => Ok(Stage::Type(TypeCheck::Str)), + ("int", None) => Ok(Stage::Type(TypeCheck::Int)), + ("bool", None) => Ok(Stage::Type(TypeCheck::Bool)), + ("float", None) => Ok(Stage::Type(TypeCheck::Float)), + ("email", None) => Ok(Stage::Type(TypeCheck::Email)), + ("url", None) => Ok(Stage::Type(TypeCheck::Url)), + ("uuid", None) => Ok(Stage::Type(TypeCheck::Uuid)), + ("redact", None) => Ok(Stage::Redact { condition: None }), + ("omit", None) => Ok(Stage::Omit), + ("hash", None) => Ok(Stage::Hash), + // Scan placeholders parse as bare identifiers (DSL §4.5). + ("pii.redact", None) => Ok(Stage::Scan { kind: ScanKind::PiiRedact }), + ("pii.detect", None) => Ok(Stage::Scan { kind: ScanKind::PiiDetect }), + ("injection.scan", None) => Ok(Stage::Scan { kind: ScanKind::InjectionScan }), + + // ----- Parameterized ----- + ("mask", Some(a)) => { + let n: usize = a.trim().parse() + .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?; + Ok(Stage::Mask { keep_last: n }) + } + ("redact", Some(a)) => { + // redact(!perm.view_ssn) — argument is a predicate expression. + let cond = parse_predicate(a).map_err(|e| ParseError::Predicate { + predicate: src.to_string(), + msg: format!("invalid redact() condition: {}", e), + })?; + Ok(Stage::Redact { condition: Some(cond) }) + } + ("hash", Some(_)) => Err(bad("hash takes no arguments")), + ("omit", Some(_)) => Err(bad( + "omit takes no arguments — for conditional omit, use a policy rule predicate", + )), + ("len", Some(a)) => { + let (min, max) = parse_range_inner(a) + .ok_or_else(|| bad(&format!("len(...) expects N..M range, got `{}`", a)))?; + let to_usize = |v: i64| -> Result { + if v < 0 { Err(bad("len bounds must be non-negative")) } + else { Ok(v as usize) } + }; + Ok(Stage::Length { + min: min.map(to_usize).transpose()?, + max: max.map(to_usize).transpose()?, + }) + } + ("enum", Some(a)) => { + let values = split_top_level(a, b',') + .into_iter() + .map(|v| { + let t = v.trim(); + // Allow either bare identifier or quoted string. + if (t.starts_with('"') && t.ends_with('"')) + || (t.starts_with('\'') && t.ends_with('\'')) + { + t[1..t.len() - 1].to_string() + } else { + t.to_string() + } + }) + .filter(|s| !s.is_empty()) + .collect::>(); + if values.is_empty() { + return Err(bad("enum() requires at least one value")); + } + Ok(Stage::Enum { values }) + } + ("regex", Some(a)) => { + let pattern = a.trim(); + let pat = if (pattern.starts_with('"') && pattern.ends_with('"')) + || (pattern.starts_with('\'') && pattern.ends_with('\'')) + { + pattern[1..pattern.len() - 1].to_string() + } else { + pattern.to_string() + }; + Ok(Stage::Regex { pattern: pat }) + } + ("validate", Some(a)) => { + // Named-validator dispatch (`validate(name)`) is in the + // spec (DSL §4.2) but not implemented in this build — + // the evaluator's no-op stub would silently let invalid + // values through. Reject at compile time so operators + // notice immediately and reach for one of the working + // alternatives: + // + // * `regex("pattern")` — inline named-regex equivalent + // * `plugin(name)` — full plugin dispatch for rich + // validation (Luhn, format-with-context, etc.) + // + // When the ValidatorRegistry slice lands, this arm flips + // back to returning `Stage::Validate { name }`. + Err(bad(&format!( + "`validate({})` — named-validator dispatch is not implemented \ + in this build. Use `regex(\"pattern\")` for a named-regex \ + equivalent, or `plugin({})` for richer validation logic.", + a.trim(), + a.trim(), + ))) + } + ("plugin", Some(a)) => Ok(Stage::Plugin { name: a.trim().to_string() }), + ("taint", Some(a)) => parse_taint(a, src), + + (other, _) => Err(bad(&format!("unknown stage `{}`", other))), + } +} + +/// Try to parse `s` as a bare range literal: `0..100`, `..500`, `0..`, `0..1M`. +fn try_parse_range(s: &str) -> Option { + if !s.contains("..") { + return None; + } + // Quick reject: must not start with a letter (would be a keyword). + let first = s.as_bytes().first().copied()?; + if first.is_ascii_alphabetic() || first == b'_' { + return None; + } + let (min, max) = parse_range_inner(s)?; + Some(Stage::Range { min, max }) +} + +/// Parse the inside of a range expression: `N..M`, `..M`, `N..`. +/// Returns `Some((min, max))` if shape is valid; `None` if it's not a range. +fn parse_range_inner(s: &str) -> Option<(Option, Option)> { + let dotdot = s.find("..")?; + let left = s[..dotdot].trim(); + let right = s[dotdot + 2..].trim(); + let min = if left.is_empty() { None } else { Some(parse_numeric_with_suffix(left)?) }; + let max = if right.is_empty() { None } else { Some(parse_numeric_with_suffix(right)?) }; + if min.is_none() && max.is_none() { + return None; // `..` alone isn't a useful range + } + Some((min, max)) +} + +/// Parse a number with optional `k/K` (×1000) or `m/M` (×1_000_000) suffix. +fn parse_numeric_with_suffix(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + let (num_part, mult) = match s.as_bytes().last().copied()? { + b'k' | b'K' => (&s[..s.len() - 1], 1_000_i64), + b'm' | b'M' => (&s[..s.len() - 1], 1_000_000_i64), + _ => (s, 1_i64), + }; + let n: i64 = num_part.parse().ok()?; + n.checked_mul(mult) +} + +/// Split `s` (a stage form like `mask(4)`) into `(head, Some(args_inside_parens))` +/// or `(head, None)` if there are no parens. +fn split_head_args(s: &str) -> Option<(&str, Option)> { + if let Some(open) = s.find('(') { + // Match the corresponding closing paren at depth 0. + let bytes = s.as_bytes(); + let mut depth = 0; + let mut close = None; + for (i, &b) in bytes.iter().enumerate().skip(open) { + match b { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { close = Some(i); break; } + } + _ => {} + } + } + let close = close?; + let head = s[..open].trim(); + if head.is_empty() { return None; } + let args = s[open + 1..close].to_string(); + // Reject trailing garbage after the closing paren. + if s[close + 1..].trim().is_empty() { + Some((head, Some(args))) + } else { + None + } + } else { + let head = s.trim(); + if head.is_empty() { None } else { Some((head, None)) } + } +} + +fn parse_taint(args: &str, src: &str) -> Result { + // taint(label) | taint(label, session) | taint(label, [session, message]) + let parts = split_top_level(args, b','); + if parts.is_empty() { + return Err(ParseError::Predicate { + predicate: src.to_string(), + msg: "taint() requires at least a label".into(), + }); + } + let label = parts[0].trim().to_string(); + if label.is_empty() { + return Err(ParseError::Predicate { + predicate: src.to_string(), + msg: "taint label must not be empty".into(), + }); + } + + let scopes = if parts.len() == 1 { + vec![TaintScope::Session] // default per DSL §4.6 + } else { + let scope_arg = parts[1..].join(","); + let scope_arg = scope_arg.trim(); + if scope_arg.starts_with('[') && scope_arg.ends_with(']') { + split_top_level(&scope_arg[1..scope_arg.len() - 1], b',') + .into_iter() + .map(|s| parse_taint_scope(s.trim(), src)) + .collect::, _>>()? + } else { + vec![parse_taint_scope(scope_arg, src)?] + } + }; + + Ok(Stage::Taint { label, scopes }) +} + +fn parse_taint_scope(s: &str, src: &str) -> Result { + match s { + "session" => Ok(TaintScope::Session), + "message" => Ok(TaintScope::Message), + other => Err(ParseError::Predicate { + predicate: src.to_string(), + msg: format!("unknown taint scope `{}` (expected `session` or `message`)", other), + }), + } +} + +// ===================================================================== +// YAML config +// ===================================================================== + +/// Top-level config — only the bits step 5a understands. +/// +/// `policy_evaluator:`, `imports:`, `global:`, `defaults:`, `tags:`, +/// `plugin_dirs:`, `plugin_settings:`, `version:` are all accepted and +/// stored opaquely; this struct deserializes leniently. +/// +/// `plugins:` (the root block) is parsed into [`PluginDeclaration`]s so +/// the runtime can look up hook names + capabilities per plugin without +/// going back to the raw YAML. +#[derive(Debug, Default, Deserialize)] +pub struct ConfigYaml { + #[serde(default)] + pub routes: HashMap, + + /// Root `plugins:` block — full declarations. + #[serde(default)] + pub plugins: Vec, + + /// Anything else top-level goes here — picked up by later steps. + #[serde(flatten)] + pub other: HashMap, +} + +#[derive(Debug, Default, Deserialize)] +pub struct RouteYaml { + /// Each entry is either a string (rule / plugin / taint) or a + /// single-key map (PDP call with reactions). See `parse_step`. + #[serde(default)] + pub policy: Vec, + + #[serde(default)] + pub post_policy: Vec, + + /// `args:` field → pipe-chain string. Compiled to per-field pipelines. + #[serde(default)] + pub args: HashMap, + + /// `result:` field → pipe-chain string. Compiled to per-field pipelines. + #[serde(default)] + pub result: HashMap, + + /// Per-route plugin overrides — only the spec-overridable keys + /// (config / capabilities / on_error). Merged on top of the root + /// `plugins:` declaration at dispatch time. + #[serde(default)] + pub plugins: HashMap, + + /// Anything else on the route (meta, taint, when) — stashed. + #[serde(flatten)] + pub other: HashMap, +} + +/// Output of [`compile_config`] — the routes that have APL blocks plus +/// the registry of plugin declarations from the root `plugins:` block. +/// +/// The two travel together because the evaluator needs both: the route +/// gives it the steps to run, and the registry gives the dispatcher the +/// hook name / kind for each plugin name referenced by those steps. +#[derive(Debug, Default)] +pub struct CompiledConfig { + pub routes: HashMap, + pub plugins: PluginRegistry, +} + +/// Compile a YAML config into a [`CompiledConfig`] (routes + plugin +/// registry). +/// +/// Routes with no APL fields populated (no `policy:` / `post_policy:` / +/// `args:` / `result:`) are **omitted from `routes`**, per apl-design §5 +/// "Routes without APL blocks fall back to legacy plugin-chain execution." +/// A route-level `plugins:` override block alone is not enough — overrides +/// only have meaning when the route actually dispatches plugins via APL +/// steps, so an override-only route is treated as legacy. +pub fn compile_config(yaml: &str) -> Result { + let cfg: ConfigYaml = serde_yaml::from_str(yaml)?; + let mut routes = HashMap::with_capacity(cfg.routes.len()); + for (route_key, raw) in cfg.routes { + if let Some(route) = compile_route(&route_key, raw)? { + routes.insert(route_key, route); + } + } + let mut plugins = PluginRegistry::with_capacity(cfg.plugins.len()); + for decl in cfg.plugins { + // Duplicate plugin names: last-one-wins for v0. The spec doesn't + // currently prescribe an error here; flag if real configs hit it. + plugins.insert(decl.name.clone(), decl); + } + Ok(CompiledConfig { routes, plugins }) +} + +fn compile_route(route_key: &str, raw: RouteYaml) -> Result, ParseError> { + let has_apl = !raw.policy.is_empty() + || !raw.post_policy.is_empty() + || !raw.args.is_empty() + || !raw.result.is_empty(); + if !has_apl { + return Ok(None); + } + Ok(Some(compile_apl_blocks(route_key, raw)?)) +} + +/// Compile the APL bodies (policy/post_policy/args/result/plugins) of a +/// single block into a `CompiledRoute`. Doesn't gate on "has any APL +/// fields" — callers that need the gate (compile_config) check first. +/// `source` is the path prefix baked into rule/pipeline diagnostics +/// (e.g. `"global.policy.all"`, `"route.get_compensation"`). +fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result { + let mut route = CompiledRoute::new(source); + for (i, entry) in raw.policy.iter().enumerate() { + let step = parse_step(entry, &format!("{}.policy[{}]", source, i))?; + route.policy.push(step_to_top_level_effect(step)?); + } + for (i, entry) in raw.post_policy.iter().enumerate() { + let step = parse_step(entry, &format!("{}.post_policy[{}]", source, i))?; + route.post_policy.push(step_to_top_level_effect(step)?); + } + for (field, chain) in &raw.args { + let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule { + rule: format!("args.{}: {:?}", field, chain), + msg: format!("{}", e), + })?; + route.args.push(FieldRule { + field: field.clone(), + pipeline, + source: format!("{}.args.{}", source, field), + }); + } + for (field, chain) in &raw.result { + let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule { + rule: format!("result.{}: {:?}", field, chain), + msg: format!("{}", e), + })?; + route.result.push(FieldRule { + field: field.clone(), + pipeline, + source: format!("{}.result.{}", source, field), + }); + } + route.plugin_overrides = raw.plugins; + Ok(route) +} + +/// Compile a single APL policy block from a `serde_yaml::Value` whose +/// shape is the body of a route's `apl:` block: +/// +/// ```yaml +/// args: +/// employee_id: "str" +/// policy: +/// - "require(authenticated)" +/// result: +/// ssn: "redact(!perm.view_ssn)" +/// post_policy: +/// - "taint(forward)" +/// ``` +/// +/// Used by external orchestrators (apl-cpex's `AplConfigVisitor`) that +/// have already located an APL block inside a larger unified-config +/// YAML. `source` is woven into per-rule / per-pipeline diagnostic paths. +/// Returns an empty `CompiledRoute` when the value is null or contains +/// no APL fields — callers that want a "is this empty?" gate can check +/// `declared_phases().is_empty()` on the result. +pub fn compile_policy_block_value( + source: &str, + block: &serde_yaml::Value, +) -> Result { + if block.is_null() { + return Ok(CompiledRoute::new(source)); + } + let raw: RouteYaml = serde_yaml::from_value(block.clone())?; + compile_apl_blocks(source, raw) +} + +// ===================================================================== +// Tests +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use crate::attributes::AttributeBag; + use crate::evaluator::Decision; + + // ----- Lexer ----- + + #[test] + fn lex_basic() { + let toks = Lexer::new("delegation.depth > 2").tokenize_all().unwrap(); + assert_eq!(toks, vec![ + Tok::Ident("delegation.depth".into()), + Tok::Gt, + Tok::IntLit(2), + ]); + } + + #[test] + fn lex_strings_both_quotes() { + let a = Lexer::new(r#""double""#).tokenize_all().unwrap(); + let b = Lexer::new(r#"'single'"#).tokenize_all().unwrap(); + assert_eq!(a, vec![Tok::StringLit("double".into())]); + assert_eq!(b, vec![Tok::StringLit("single".into())]); + } + + #[test] + fn lex_keywords_vs_idents() { + let toks = Lexer::new("require(role.hr) & authenticated").tokenize_all().unwrap(); + assert_eq!(toks, vec![ + Tok::Require, Tok::LParen, + Tok::Ident("role.hr".into()), + Tok::RParen, Tok::And, + Tok::Ident("authenticated".into()), + ]); + } + + #[test] + fn lex_rejects_single_equals() { + let err = Lexer::new("a = 1").tokenize_all().unwrap_err(); + assert!(format!("{}", err).contains("expected `==`")); + } + + // ----- Predicate parser ----- + + #[test] + fn pred_bare_identifier() { + let e = parse_predicate("authenticated").unwrap(); + assert_eq!(e, Expression::Condition(Condition::IsTrue { key: "authenticated".into() })); + } + + #[test] + fn pred_comparison() { + let e = parse_predicate("delegation.depth > 2").unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Gt, + value: Literal::Int(2), + }) + ); + } + + #[test] + fn pred_contains() { + let e = parse_predicate(r#"session.labels contains "PII""#).unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::Comparison { + key: "session.labels".into(), + op: CompareOp::Contains, + value: Literal::String("PII".into()), + }) + ); + } + + #[test] + fn pred_precedence_or_lowest_and_middle_not_highest() { + // `!a & b | c` should parse as `(!a & b) | c`. + let e = parse_predicate("!a & b | c").unwrap(); + match e { + Expression::Or(parts) => { + assert_eq!(parts.len(), 2); + match &parts[0] { + Expression::And(_) => {} + other => panic!("first OR branch should be AND, got {:?}", other), + } + } + other => panic!("top-level should be OR, got {:?}", other), + } + } + + #[test] + fn pred_parens_override_precedence() { + // `(role.finance | role.admin) & !delegated` from DSL §2.5. + let e = parse_predicate("(role.finance | role.admin) & !delegated").unwrap(); + match e { + Expression::And(parts) => { + assert_eq!(parts.len(), 2); + matches!(parts[0], Expression::Or(_)); + matches!(parts[1], Expression::Not(_)); + } + other => panic!("expected top-level AND, got {:?}", other), + } + } + + #[test] + fn pred_require_rejected_as_predicate() { + // require() is a rule-level shorthand per DSL §8, not a sub-predicate. + // Trying to use it inside a predicate expression must fail clearly. + let err = parse_predicate("require(authenticated)").unwrap_err(); + assert!(format!("{}", err).contains("rule-level shorthand")); + } + + #[test] + fn rule_require_single_arg_desugars_to_isfalse_and_deny() { + // require(X) → Rule { condition: IsFalse(X), action: Deny } (DSL §8.1) + let r = parse_rule("require(authenticated)", "test").unwrap(); + assert!(matches!(r.effects.as_slice(), [Effect::Deny { reason: None, code: None }])); + assert_eq!( + r.condition, + Expression::Condition(Condition::IsFalse { key: "authenticated".into() }), + ); + } + + #[test] + fn rule_require_comma_is_and_desugars_to_or_of_isfalse() { + // require(X, Y) → Or([IsFalse(X), IsFalse(Y)]) + Deny (DSL §8.1) + // i.e., "deny if any are falsy" = "any are falsy → deny" + let r = parse_rule("require(role.hr, perm.view_ssn)", "test").unwrap(); + assert_eq!( + r.condition, + Expression::Or(vec![ + Expression::Condition(Condition::IsFalse { key: "role.hr".into() }), + Expression::Condition(Condition::IsFalse { key: "perm.view_ssn".into() }), + ]), + ); + } + + #[test] + fn rule_require_pipe_is_or_desugars_to_and_of_isfalse() { + // require(X | Y) → And([IsFalse(X), IsFalse(Y)]) + Deny (DSL §8.1) + // i.e., "deny only if all are falsy" = "all are falsy → deny" + let r = parse_rule("require(role.finance | role.admin)", "test").unwrap(); + assert_eq!( + r.condition, + Expression::And(vec![ + Expression::Condition(Condition::IsFalse { key: "role.finance".into() }), + Expression::Condition(Condition::IsFalse { key: "role.admin".into() }), + ]), + ); + } + + #[test] + fn rule_require_mixed_rejected() { + let err = parse_rule("require(a, b | c)", "test").unwrap_err(); + assert!(format!("{}", err).contains("cannot mix")); + } + + #[test] + fn pred_eq_with_ident_rhs_rejected_with_in_hint() { + // `subject.type == allowed_types` — `==` doesn't take an ident RHS, + // and the error should hint at `in` for set membership. + let err = parse_predicate("subject.type == allowed_types").unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("RHS-as-identifier")); + assert!(msg.contains("set membership use")); + } + + #[test] + fn pred_in_set_basic() { + let e = parse_predicate("subject.type in allowed_types").unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::InSet { + value_key: "subject.type".into(), + set_key: "allowed_types".into(), + negate: false, + }), + ); + } + + #[test] + fn pred_not_in_set() { + let e = parse_predicate("subject.type not in blocked_types").unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::InSet { + value_key: "subject.type".into(), + set_key: "blocked_types".into(), + negate: true, + }), + ); + } + + #[test] + fn pred_exists_basic() { + let e = parse_predicate("exists(args.amount)").unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::Exists { key: "args.amount".into() }), + ); + } + + #[test] + fn pred_exists_inside_compound() { + // exists() is a sub-predicate (unlike require) — can nest in & / |. + let e = parse_predicate("exists(args.amount) & args.amount > 0").unwrap(); + match e { + Expression::And(parts) => { + assert_eq!(parts.len(), 2); + assert_eq!( + parts[0], + Expression::Condition(Condition::Exists { key: "args.amount".into() }), + ); + } + other => panic!("expected And, got {:?}", other), + } + } + + #[test] + fn pred_exists_requires_paren_and_ident() { + assert!(parse_predicate("exists").is_err()); + assert!(parse_predicate("exists()").is_err()); + assert!(parse_predicate("exists(authenticated").is_err()); + } + + #[test] + fn pred_trailing_tokens_rejected() { + let err = parse_predicate("a b").unwrap_err(); + assert!(format!("{}", err).contains("trailing")); + } + + // ----- Rule parser ----- + + #[test] + fn rule_predicate_action_form() { + let r = parse_rule("delegation.depth > 2: deny", "test").unwrap(); + match r.effects.as_slice() { + [Effect::Deny { .. }] => {} + other => panic!("expected [Deny], got {:?}", other), + } + match r.condition { + Expression::Condition(Condition::Comparison { .. }) => {} + other => panic!("expected Comparison, got {:?}", other), + } + } + + #[test] + fn rule_predicate_only_defaults_to_deny() { + // DSL §2: missing action defaults to deny. + let r = parse_rule("!authenticated", "test").unwrap(); + assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }])); + } + + #[test] + fn rule_explicit_allow() { + let r = parse_rule("role.admin: allow", "test").unwrap(); + assert!(matches!(r.effects.as_slice(), [Effect::Allow])); + } + + #[test] + fn rule_bare_action_unconditional() { + // Bare `- deny` and `- allow` are unconditional rules with + // Expression::Always as the predicate (DSL §3.1). + let r = parse_rule("deny", "test").unwrap(); + assert_eq!(r.condition, Expression::Always); + assert!(matches!(r.effects.as_slice(), [Effect::Deny { reason: None, code: None }])); + + let r = parse_rule("allow", "test").unwrap(); + assert_eq!(r.condition, Expression::Always); + assert!(matches!(r.effects.as_slice(), [Effect::Allow])); + } + + #[test] + fn rule_step_kinds_rejected_clearly() { + for s in ["plugin(rate_limiter)", "cedar:(action: read)", "opa(path)", "taint(audit)"] { + let err = parse_rule(s, "test").unwrap_err(); + assert!( + matches!(err, ParseError::UnsupportedStep { .. }), + "expected UnsupportedStep for `{}`, got {:?}", s, err + ); + } + } + + #[test] + fn rule_deny_with_unquoted_arg_rejected() { + // `deny "reason"` (space-separated, no parens) is not a valid + // form. The supported reason-carrying shape is + // `deny('reason')` / `deny('reason', 'code')` per DSL §3 and + // the E1 `code` extension. + let err = parse_rule(r#"authenticated: deny "go away""#, "test").unwrap_err(); + assert!(format!("{}", err).contains("unsupported action")); + } + + #[test] + fn rule_deny_with_quoted_reason_accepted() { + // `deny('reason')` — single-arg form. Reason landing on the + // effect; code defaulting to None. + let r = parse_rule(r#"delegation.depth > 2: deny('too deep')"#, "test").unwrap(); + assert!(matches!( + r.effects.as_slice(), + [Effect::Deny { reason: Some(s), code: None }] if s == "too deep" + )); + } + + #[test] + fn rule_deny_with_reason_and_code_accepted() { + // `deny('reason', 'code')` — E1 extension. Both reason and + // author-supplied code surface in the violation. + let r = parse_rule( + r#"delegation.depth > 2: deny('too deep', 'delegation.depth_exceeded')"#, + "test", + ) + .unwrap(); + match r.effects.as_slice() { + [Effect::Deny { reason: Some(reason), code: Some(code) }] => { + assert_eq!(reason, "too deep"); + assert_eq!(code, "delegation.depth_exceeded"); + } + other => panic!("expected Deny with reason+code, got {:?}", other), + } + } + + #[test] + fn rule_deny_with_too_many_args_rejected() { + // Cap on positional args — `deny(reason, code)` is the limit. + let err = parse_rule(r#"x: deny('a', 'b', 'c')"#, "test").unwrap_err(); + assert!(format!("{}", err).contains("at most two args")); + } + + #[test] + fn rule_deny_with_unquoted_args_in_call_rejected() { + // The args MUST be quoted; bare identifiers aren't legal. + let err = parse_rule(r#"x: deny(bare, identifier)"#, "test").unwrap_err(); + assert!(format!("{}", err).contains("expected a quoted string")); + } + + // ----- E1: when/do canonical form ----- + + fn parse_step_yaml(yaml: &str) -> Result { + let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + parse_step(&v, "test") + } + + #[test] + fn when_do_single_effect_deny() { + // do: deny — single string value, no list. + let step = parse_step_yaml("when: delegation.depth > 2\ndo: deny").unwrap(); + match step { + Step::Rule(rule) => { + assert!(matches!( + rule.condition, + Expression::Condition(Condition::Comparison { .. }) + )); + assert!(matches!( + rule.effects.as_slice(), + [Effect::Deny { reason: None, code: None }] + )); + } + other => panic!("expected Step::Rule, got {:?}", other), + } + } + + #[test] + fn when_do_single_effect_deny_with_reason_and_code() { + // The E1 `deny('reason', 'code')` extension works inside `do:` too. + let step = parse_step_yaml( + "when: delegation.depth > 2\ndo: deny('too deep', 'delegation.depth_exceeded')", + ) + .unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + match rule.effects.as_slice() { + [Effect::Deny { reason: Some(r), code: Some(c) }] => { + assert_eq!(r, "too deep"); + assert_eq!(c, "delegation.depth_exceeded"); + } + other => panic!("expected Deny+reason+code, got {:?}", other), + } + } + + #[test] + fn when_do_multi_effect_list() { + // The headline demo case: fan-out from one predicate. + // do: [plugin(audit_logger), taint(unauth), deny('refused')] + let yaml = r#" +when: "!role.hr" +do: + - "plugin(audit_logger)" + - "taint(unauth, session)" + - "deny('refused', 'role.hr_required')" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 3); + assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger")); + assert!(matches!( + rule.effects[1], + Effect::Taint { ref label, .. } if label == "unauth" + )); + match &rule.effects[2] { + Effect::Deny { reason: Some(r), code: Some(c) } => { + assert_eq!(r, "refused"); + assert_eq!(c, "role.hr_required"); + } + other => panic!("expected Deny+reason+code, got {:?}", other), + } + } + + #[test] + fn when_do_key_order_does_not_matter() { + // YAML maps are unordered; `do:` first should parse the same. + let step = + parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap(); + assert!(matches!(step, Step::Rule(_))); + } + + #[test] + fn when_do_with_unknown_key_rejected() { + // Typo guard — surface unknown keys instead of silently dropping. + let err = parse_step_yaml("when: x\ndo: deny\nwhne: typo").unwrap_err(); + assert!(format!("{}", err).contains("unexpected key")); + } + + #[test] + fn when_do_empty_do_list_rejected() { + // An empty `do:` is almost certainly an author mistake; + // require at least one effect. + let err = parse_step_yaml("when: x\ndo: []").unwrap_err(); + assert!(format!("{}", err).contains("no effects")); + } + + // ----- E1: shorthand multi-effect map (predicate: [list]) ----- + + #[test] + fn shorthand_multi_effect_map() { + // Shorthand for the canonical when/do form. The predicate is + // the map's only key, the value is a list of effects. + let yaml = r#" +"!role.hr": + - "plugin(audit_logger)" + - "deny('unauthorized')" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 2); + assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger")); + assert!(matches!( + rule.effects[1], + Effect::Deny { reason: Some(ref r), code: None } if r == "unauthorized" + )); + } + + #[test] + fn shorthand_multi_effect_map_with_nested_delegate() { + // Map-form effects (like `delegate:`) work inside a shorthand + // list, exercising the parse_effect_value path. + let yaml = r#" +"role.hr": + - delegate: + plugin: workday-oauth + config: + audience: workday-api + - "plugin(audit_logger)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 2); + assert!(matches!(rule.effects[0], Effect::Delegate(_))); + assert!(matches!(rule.effects[1], Effect::Plugin { .. })); + } + + #[test] + fn cedar_with_list_body_still_parses_as_pdp() { + // Regression guard — `cedar:` and other PDP keys whose body + // happens to be list-shaped (e.g. when the author embeds a + // bare reaction list) must NOT be reinterpreted as a + // shorthand multi-effect map. + // + // Cedar bodies in production are maps with `action`/`resource` + // keys — we don't actually accept a Sequence body, but the + // shorthand-list detector explicitly excludes known PDP + // dialect keys so the failure mode here is the existing PDP + // body error, not a shorthand misparse. + let err = parse_step_yaml("cedar: [oh no]").unwrap_err(); + // Existing PDP body validator complains about the shape — + // proves we didn't try to read `cedar` as a predicate. + assert!(format!("{}", err).contains("body must be a map")); + } + + #[test] + fn shorthand_multi_effect_empty_list_rejected() { + let err = parse_step_yaml(r#""x": []"#).unwrap_err(); + assert!(format!("{}", err).contains("no effects")); + } + + // ----- E2: content effects in do: (field pipe chains) ----- + + #[test] + fn when_do_with_field_op_result_redact() { + // The headline E2 case: `result.salary | redact` as an effect + // inside a do: list, alongside other effect kinds. + let yaml = r#" +when: "!perm.view_ssn" +do: + - "plugin(audit_logger)" + - "result.salary | redact" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 2); + assert!(matches!(rule.effects[0], Effect::Plugin { .. })); + match &rule.effects[1] { + Effect::FieldOp { path, stages } => { + assert_eq!(path, "result.salary"); + assert_eq!(stages.len(), 1, "single `redact` stage"); + } + other => panic!("expected FieldOp, got {:?}", other), + } + } + + #[test] + fn when_do_with_field_op_args_mask() { + // `args.card_number | mask(4)` — args side + parametrised stage. + let yaml = r#" +when: role.support +do: "args.card_number | mask(4)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + match &rule.effects[..] { + [Effect::FieldOp { path, stages }] => { + assert_eq!(path, "args.card_number"); + assert_eq!(stages.len(), 1); + } + other => panic!("expected single FieldOp, got {:?}", other), + } + } + + #[test] + fn when_do_with_chained_field_op() { + // Chained stages — type check + content effect. Uses stages + // the pipeline parser actually knows about (`str` and `mask`). + let yaml = r#" +when: role.support +do: "args.card_number | str | mask(4)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + match &rule.effects[..] { + [Effect::FieldOp { path, stages }] => { + assert_eq!(path, "args.card_number"); + assert_eq!(stages.len(), 2, "two-stage chain"); + } + other => panic!("expected single FieldOp, got {:?}", other), + } + } + + #[test] + fn field_op_invalid_path_falls_through() { + // `role.hr | redact` looks like a pipe chain but the path + // doesn't start with `args.` / `result.`. We refuse to treat + // it as a FieldOp; instead it falls through to the predicate + // parser, which will fail with a more specific error. + let yaml = r#"do: "role.hr | redact""#; + let _ = parse_step_yaml(&format!("when: true\n{}", yaml)); + // The exact failure mode here isn't load-bearing — what matters + // is we don't silently produce an unconditional FieldOp with a + // bogus path. So just confirm we either error or produce + // *something other than* a FieldOp. + let step = parse_step_yaml("when: true\ndo: \"role.hr | redact\""); + match step { + Ok(Step::Rule(rule)) => { + assert!( + !matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]), + "bare `role.hr` must NOT parse as a FieldOp path" + ); + } + Err(_) => {} // also fine + other => panic!("unexpected: {:?}", other), + } + } + + #[test] + fn field_op_empty_chain_rejected() { + // `args.x |` (trailing pipe with nothing after) — author bug. + let yaml = r#"when: true +do: "args.x | ""#; + let _ = parse_step_yaml(yaml); // shape varies by YAML parser, just ensure no panic + } + + #[test] + fn shorthand_multi_effect_with_field_op() { + // Shorthand `predicate: [list]` with a content effect. + let yaml = r#" +"!perm.view_ssn": + - "plugin(audit_logger)" + - "result.ssn | redact" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Step::Rule"); + }; + assert_eq!(rule.effects.len(), 2); + assert!(matches!(rule.effects[1], Effect::FieldOp { .. })); + } + + #[test] + fn find_top_level_pipe_skips_inside_parens() { + // Top-level `|` between path and chain → returns its index. + // Inner `|` inside `(...)` or quotes is ignored. + assert_eq!(find_top_level_pipe("args.x | mask(4)"), Some(7)); + assert_eq!(find_top_level_pipe("validate(luhn)"), None); + assert_eq!(find_top_level_pipe(r#"args.x | mask("a|b")"#), Some(7)); + // No top-level pipe even with a `|` inside the parameter set. + assert_eq!(find_top_level_pipe("mask(a|b)"), None); + } + + // ----- E3: sequential: / parallel: parsing ----- + + #[test] + fn top_level_sequential() { + // `- sequential: [list]` as a top-level policy step. + let yaml = r#" +sequential: + - "plugin(rate_limiter)" + - "plugin(audit_logger)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Rule"); + }; + assert!(matches!(rule.condition, Expression::Always)); + match rule.effects.as_slice() { + [Effect::Sequential(inner)] => { + assert_eq!(inner.len(), 2); + assert!(matches!(inner[0], Effect::Plugin { .. })); + assert!(matches!(inner[1], Effect::Plugin { .. })); + } + other => panic!("expected single Sequential effect, got {:?}", other), + } + } + + #[test] + fn top_level_parallel() { + let yaml = r#" +parallel: + - "plugin(pii_scanner)" + - "plugin(nemo_guardrails)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Rule"); + }; + match rule.effects.as_slice() { + [Effect::Parallel(inner)] => { + assert_eq!(inner.len(), 2); + } + other => panic!("expected single Parallel effect, got {:?}", other), + } + } + + #[test] + fn parallel_inside_do_body() { + // The DSL spec's "Conditional parallel" example: a `when:` + // rule whose `do:` is a single parallel block. + let yaml = r#" +when: args.include_ssn == true +do: + parallel: + - "plugin(pii_scanner)" + - "plugin(nemo_guardrails)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Rule"); + }; + match rule.effects.as_slice() { + [Effect::Parallel(inner)] => assert_eq!(inner.len(), 2), + other => panic!("expected Parallel in do:, got {:?}", other), + } + } + + #[test] + fn parallel_rejects_field_op_at_parse_time() { + // FieldOp inside Parallel should fail at parse, not at runtime. + let yaml = r#" +parallel: + - "plugin(audit)" + - "args.ssn | redact" +"#; + let err = parse_step_yaml(yaml).unwrap_err(); + assert!(format!("{}", err).contains("mutation"), "got: {}", err); + } + + #[test] + fn parallel_rejects_delegate_at_parse_time() { + let yaml = r#" +parallel: + - "plugin(audit)" + - "delegate(workday)" +"#; + let err = parse_step_yaml(yaml).unwrap_err(); + assert!(format!("{}", err).contains("mutation")); + } + + #[test] + fn sequential_allows_mutations() { + // The escape valve — Sequential lets mutations through. + let yaml = r#" +sequential: + - "args.ssn | redact" + - "plugin(audit)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { panic!("expected Rule") }; + match rule.effects.as_slice() { + [Effect::Sequential(inner)] => { + assert!(matches!(inner[0], Effect::FieldOp { .. })); + assert!(matches!(inner[1], Effect::Plugin { .. })); + } + other => panic!("got {:?}", other), + } + } + + #[test] + fn parallel_empty_list_rejected() { + let err = parse_step_yaml("parallel: []").unwrap_err(); + assert!(format!("{}", err).contains("empty")); + } + + #[test] + fn sequential_empty_list_rejected() { + let err = parse_step_yaml("sequential: []").unwrap_err(); + assert!(format!("{}", err).contains("empty")); + } + + #[test] + fn nested_orchestration() { + // `sequential: [plugin, parallel: [plugin, plugin]]` — the + // parser handles arbitrary nesting through parse_effect_value. + let yaml = r#" +sequential: + - "plugin(rate_limiter)" + - parallel: + - "plugin(pii_scanner)" + - "plugin(nemo)" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { panic!("expected Rule") }; + let Effect::Sequential(outer) = &rule.effects[0] else { + panic!("expected Sequential"); + }; + assert_eq!(outer.len(), 2); + assert!(matches!(outer[0], Effect::Plugin { .. })); + match &outer[1] { + Effect::Parallel(inner) => assert_eq!(inner.len(), 2), + other => panic!("expected nested Parallel, got {:?}", other), + } + } + + // ----- Colon-splitting edge cases ----- + + #[test] + fn split_respects_quotes_and_parens() { + // The `:` inside parens / quotes shouldn't be the separator. + let r = parse_rule( + r#"session.labels contains "a:b": deny"#, + "test", + ).unwrap(); + assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }])); + if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition { + assert_eq!(value, Literal::String("a:b".into())); + } else { + panic!("expected Comparison"); + } + } + + // ----- YAML compilation ----- + + #[test] + fn compile_simple_route() { + let yaml = r#" +routes: + get_compensation: + policy: + - "require(authenticated)" + - "require(role.hr | role.finance)" + - "delegation.depth > 2 & include_ssn: deny" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("get_compensation").expect("route missing"); + assert_eq!(route.policy.len(), 3); + assert!(route.declared_phases().contains(crate::rules::Phase::Policy)); + } + + #[test] + fn compile_omits_routes_without_apl_blocks() { + // A route with no APL blocks (no policy / post_policy / args / + // result) is a "legacy" route per apl-design §5 and must be + // omitted from the compiled output. Unknown route keys (e.g. + // legacy CPEX `priority`) are stashed in `other`, not errored. + let yaml = r#" +routes: + legacy: + priority: 50 + apl_route: + policy: + - "require(authenticated)" +"#; + let routes = compile_config(yaml).unwrap().routes; + assert!(routes.contains_key("apl_route")); + assert!(!routes.contains_key("legacy"), "legacy route should be omitted, not compiled"); + } + + #[test] + fn compile_unknown_top_level_keys_ignored() { + let yaml = r#" +version: "0.1" +policy_evaluator: + kind: apl +plugins: + - name: rate_limiter + kind: native +imports: + - "./shared.yaml" +routes: + ping: + policy: + - "require(authenticated)" +"#; + let routes = compile_config(yaml).unwrap().routes; + assert!(routes.contains_key("ping")); + } + + #[test] + fn compile_propagates_rule_errors_with_source() { + let yaml = r#" +routes: + bad: + policy: + - "subject.id == garbage_ident" +"#; + let err = compile_config(yaml).unwrap_err(); + // RHS-as-identifier is rejected; the error mentions the offending input. + let msg = format!("{}", err); + assert!( + msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"), + "error message should reference the failure: {}", msg, + ); + } + + #[test] + fn compile_plugin_step_string_form() { + let yaml = r#" +routes: + rate_limited: + policy: + - "plugin(rate_limiter)" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("rate_limited").unwrap(); + assert_eq!(route.policy.len(), 1); + match &route.policy[0] { + Effect::Plugin { name } => assert_eq!(name, "rate_limiter"), + other => panic!("expected Effect::Plugin, got {:?}", other), + } + } + + #[test] + fn compile_taint_step_string_form() { + let yaml = r#" +routes: + audit_marked: + policy: + - "taint(audit, session)" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("audit_marked").unwrap(); + match &route.policy[0] { + Effect::Taint { label, scopes } => { + assert_eq!(label, "audit"); + assert_eq!(scopes, &vec![TaintScope::Session]); + } + other => panic!("expected Effect::Taint, got {:?}", other), + } + } + + #[test] + fn compile_pdp_call_cedar_map_form() { + // Cedar uses the `cedar:` key with args inline + on_deny/on_allow. + let yaml = r#" +routes: + authz_check: + policy: + - cedar: + action: read + resource: employee + on_deny: + - deny + on_allow: + - "plugin(audit_logger)" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("authz_check").unwrap(); + match &route.policy[0] { + Effect::Pdp { call, on_deny, on_allow } => { + assert_eq!(call.dialect, PdpDialect::Cedar); + // Cedar args are a map: action + resource (with reaction + // keys stripped out). + let args_map = call.args.as_mapping().expect("cedar args should be a map"); + assert!(args_map.contains_key(serde_yaml::Value::String("action".into()))); + assert!(args_map.contains_key(serde_yaml::Value::String("resource".into()))); + assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into()))); + assert_eq!(on_deny.len(), 1); + assert_eq!(on_allow.len(), 1); + } + other => panic!("expected Effect::Pdp, got {:?}", other), + } + } + + #[test] + fn compile_pdp_call_cedarling_map_form() { + // `cedarling:` is its own dialect — same map shape as `cedar:` + // but routes to the Cedarling-backed resolver in the + // PdpRouter, letting cedar-direct and cedarling coexist. + let yaml = r#" +routes: + authz_check: + policy: + - cedarling: + action: read + resource: employee + on_deny: + - deny +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("authz_check").unwrap(); + match &route.policy[0] { + Effect::Pdp { call, on_deny, .. } => { + assert_eq!(call.dialect, PdpDialect::Cedarling); + let args_map = call.args.as_mapping().expect("cedarling args should be a map"); + assert!(args_map.contains_key(serde_yaml::Value::String("action".into()))); + assert!(args_map.contains_key(serde_yaml::Value::String("resource".into()))); + assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into()))); + assert_eq!(on_deny.len(), 1); + } + other => panic!("expected Effect::Pdp, got {:?}", other), + } + } + + #[test] + fn compile_pdp_call_opa_paren_form() { + // OPA uses `opa("path"):` with the path inside parens + body is reactions. + let yaml = r#" +routes: + opa_check: + policy: + - 'opa("hr/compensation/deny"):': + on_deny: + - deny +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("opa_check").unwrap(); + match &route.policy[0] { + Effect::Pdp { call, on_deny, .. } => { + assert_eq!(call.dialect, PdpDialect::Opa); + // OPA args are a string (the path). + assert!(call.args.as_str().unwrap().contains("hr/compensation/deny")); + assert_eq!(on_deny.len(), 1); + } + other => panic!("expected Effect::Pdp, got {:?}", other), + } + } + + #[test] + fn compile_pdp_unknown_dialect_becomes_custom() { + let yaml = r#" +routes: + custom_pdp: + policy: + - my_engine: + on_deny: [deny] +"#; + let routes = compile_config(yaml).unwrap().routes; + match &routes.get("custom_pdp").unwrap().policy[0] { + Effect::Pdp { call, .. } => { + assert_eq!(call.dialect, PdpDialect::Custom("my_engine".into())); + } + other => panic!("expected Pdp, got {:?}", other), + } + } + + // ----- End-to-end with evaluator ----- + + #[tokio::test] + async fn end_to_end_hr_compensation() { + let yaml = r#" +routes: + get_compensation: + policy: + - "require(authenticated)" + - "require(role.hr | role.finance)" + - "delegation.depth > 2: deny" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("get_compensation").unwrap(); + + let pdp: std::sync::Arc = + std::sync::Arc::new(NullPdpResolver); + let plugins: std::sync::Arc = + std::sync::Arc::new(NullPluginInvoker); + let delegations: std::sync::Arc = + std::sync::Arc::new(crate::NoopDelegationInvoker); + + // Alice: authenticated, hr role, depth=1 → allow. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("role.hr", true); + bag.set("delegation.depth", 1_i64); + assert_eq!( + crate::evaluate_effects(&route.policy, &mut bag, &pdp, &plugins, &delegations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision, + Decision::Allow, + ); + + // Same Alice but depth=3 → deny (third rule fires). + bag.set("delegation.depth", 3_i64); + match crate::evaluate_effects(&route.policy, &mut bag, &pdp, &plugins, &delegations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy[2]"), "expected policy[2], got {}", rule_source); + } + d => panic!("expected Deny, got {:?}", d), + } + + // Bob: authenticated but neither hr nor finance → deny on rule 1. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 1_i64); + match crate::evaluate_effects(&route.policy, &mut bag, &pdp, &plugins, &delegations, crate::DispatchPhase::Pre, &mut crate::route::RoutePayload::new(serde_json::Value::Null)).await.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy[1]"), "expected policy[1], got {}", rule_source); + } + d => panic!("expected Deny, got {:?}", d), + } + } + + // Test fixtures for async evaluator — null resolvers that nothing in + // a pure-rule route should ever invoke. + struct NullPdpResolver; + #[async_trait::async_trait] + impl crate::PdpResolver for NullPdpResolver { + fn dialect(&self) -> crate::PdpDialect { crate::PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &crate::PdpCall, + _bag: &crate::AttributeBag, + ) -> Result { + panic!("NullPdpResolver should not be invoked in pure-rule tests"); + } + } + + struct NullPluginInvoker; + #[async_trait::async_trait] + impl crate::PluginInvoker for NullPluginInvoker { + async fn invoke( + &self, + _name: &str, + _bag: &crate::AttributeBag, + _invocation: crate::PluginInvocation<'_>, + ) -> Result { + panic!("NullPluginInvoker should not be invoked in pure-rule tests"); + } + } + + // ----- Pipeline parsing ----- + + #[test] + fn pipeline_simple_bare_stages() { + let p = parse_pipeline("str").unwrap(); + assert_eq!(p.stages, vec![Stage::Type(TypeCheck::Str)]); + + let p = parse_pipeline("omit").unwrap(); + assert_eq!(p.stages, vec![Stage::Omit]); + + let p = parse_pipeline("hash").unwrap(); + assert_eq!(p.stages, vec![Stage::Hash]); + } + + #[test] + fn pipeline_chains_split_on_pipe() { + let p = parse_pipeline("str | mask(4)").unwrap(); + assert_eq!(p.stages, vec![ + Stage::Type(TypeCheck::Str), + Stage::Mask { keep_last: 4 }, + ]); + + let p = parse_pipeline("int | 0..1M").unwrap(); + assert_eq!(p.stages, vec![ + Stage::Type(TypeCheck::Int), + Stage::Range { min: Some(0), max: Some(1_000_000) }, + ]); + } + + #[test] + fn pipeline_pipe_inside_parens_does_not_split() { + // `redact(!a | b)` is one stage; the inner `|` is OR inside a + // predicate condition, not a chain separator. + let p = parse_pipeline("str | redact(!perm.view_ssn | role.admin)").unwrap(); + assert_eq!(p.stages.len(), 2); + match &p.stages[1] { + Stage::Redact { condition: Some(_) } => {} + other => panic!("expected Redact with condition, got {:?}", other), + } + } + + #[test] + fn pipeline_length_constraints() { + let p = parse_pipeline("len(..500)").unwrap(); + assert_eq!(p.stages, vec![Stage::Length { min: None, max: Some(500) }]); + let p = parse_pipeline("len(10..50)").unwrap(); + assert_eq!(p.stages, vec![Stage::Length { min: Some(10), max: Some(50) }]); + let p = parse_pipeline("len(8..)").unwrap(); + assert_eq!(p.stages, vec![Stage::Length { min: Some(8), max: None }]); + } + + #[test] + fn pipeline_range_with_suffixes() { + let p = parse_pipeline("0..10k").unwrap(); + assert_eq!(p.stages, vec![Stage::Range { min: Some(0), max: Some(10_000) }]); + let p = parse_pipeline("0..1M").unwrap(); + assert_eq!(p.stages, vec![Stage::Range { min: Some(0), max: Some(1_000_000) }]); + let p = parse_pipeline("..500").unwrap(); + assert_eq!(p.stages, vec![Stage::Range { min: None, max: Some(500) }]); + } + + #[test] + fn pipeline_enum_unquoted_and_quoted() { + let p = parse_pipeline("enum(low, medium, high)").unwrap(); + assert_eq!(p.stages, vec![Stage::Enum { + values: vec!["low".into(), "medium".into(), "high".into()], + }]); + let p = parse_pipeline(r#"enum("a", "b")"#).unwrap(); + assert_eq!(p.stages, vec![Stage::Enum { + values: vec!["a".into(), "b".into()], + }]); + } + + #[test] + fn pipeline_redact_with_predicate_condition() { + let p = parse_pipeline("str | redact(!perm.view_ssn)").unwrap(); + assert_eq!(p.stages.len(), 2); + match &p.stages[1] { + Stage::Redact { condition: Some(Expression::Not(inner)) } => { + match inner.as_ref() { + Expression::Condition(Condition::IsTrue { key }) => { + assert_eq!(key, "perm.view_ssn"); + } + other => panic!("expected IsTrue(perm.view_ssn), got {:?}", other), + } + } + other => panic!("expected Redact with Not condition, got {:?}", other), + } + } + + #[test] + fn pipeline_taint_scopes() { + let p = parse_pipeline("taint(PII)").unwrap(); + assert_eq!(p.stages, vec![Stage::Taint { + label: "PII".into(), + scopes: vec![TaintScope::Session], + }]); + let p = parse_pipeline("taint(PII, message)").unwrap(); + assert_eq!(p.stages, vec![Stage::Taint { + label: "PII".into(), + scopes: vec![TaintScope::Message], + }]); + let p = parse_pipeline("taint(PII, [session, message])").unwrap(); + assert_eq!(p.stages, vec![Stage::Taint { + label: "PII".into(), + scopes: vec![TaintScope::Session, TaintScope::Message], + }]); + } + + #[test] + fn pipeline_unknown_stage_rejected() { + let err = parse_pipeline("nonsense").unwrap_err(); + assert!(format!("{}", err).contains("unknown stage")); + } + + #[test] + fn pipeline_omit_with_args_rejected() { + // omit has no conditional form per DSL §4.1. + let err = parse_pipeline("omit(!perm.x)").unwrap_err(); + assert!(format!("{}", err).contains("omit takes no arguments")); + } + + // ----- YAML compilation with pipelines ----- + + #[test] + fn compile_route_with_args_and_result() { + let yaml = r#" +routes: + get_compensation: + args: + employee_id: "uuid" + amount: "int | 0..1M" + result: + ssn: "str | redact(!perm.view_ssn)" + employee_id: "str | mask(4)" + internal_notes: "omit" +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("get_compensation").expect("missing route"); + assert_eq!(route.args.len(), 2); + assert_eq!(route.result.len(), 3); + + // Pull out the ssn pipeline and confirm shape. + let ssn = route.result.iter().find(|f| f.field == "ssn").unwrap(); + assert_eq!(ssn.pipeline.stages.len(), 2); + assert!(matches!(ssn.pipeline.stages[0], Stage::Type(TypeCheck::Str))); + assert!(matches!(ssn.pipeline.stages[1], Stage::Redact { condition: Some(_) })); + + // declared_phases should include Result and Args now. + let phases = route.declared_phases(); + assert!(phases.contains(crate::rules::Phase::Args)); + assert!(phases.contains(crate::rules::Phase::Result)); + } + + #[test] + fn compile_route_with_only_args_still_compiles() { + // A route with no `policy:` but with `args:` validators is still + // an APL route (declared_phases is non-empty). + let yaml = r#" +routes: + validate_only: + args: + employee_id: "uuid" +"#; + let routes = compile_config(yaml).unwrap().routes; + assert!(routes.contains_key("validate_only")); + } + + #[test] + fn compile_propagates_pipeline_parse_errors() { + let yaml = r#" +routes: + bad: + result: + x: "nonsense" +"#; + let err = compile_config(yaml).unwrap_err(); + assert!(format!("{}", err).contains("unknown stage")); + } + + // ----- plugins: block + route-level overrides ----- + + #[test] + fn compile_captures_root_plugins_block_into_registry() { + let yaml = r#" +plugins: + - name: rate_limiter + kind: native + hooks: [tool_pre_invoke] + capabilities: [read_subject] + config: + max_requests: 100 + - name: audit + kind: native + hooks: [tool_post_invoke] +routes: + get_compensation: + policy: + - "plugin(rate_limiter)" +"#; + let cfg = compile_config(yaml).unwrap(); + assert_eq!(cfg.plugins.len(), 2); + let rl = cfg.plugins.get("rate_limiter").unwrap(); + assert_eq!(rl.kind, "native"); + assert_eq!(rl.hooks, vec!["tool_pre_invoke".to_string()]); + assert_eq!(rl.capabilities, vec!["read_subject".to_string()]); + // The route should still compile (uses plugin(rate_limiter)). + assert!(cfg.routes.contains_key("get_compensation")); + } + + #[test] + fn compile_captures_route_level_plugin_overrides() { + let yaml = r#" +plugins: + - name: rate_limiter + kind: native + hooks: [tool_pre_invoke] + config: + max_requests: 100 +routes: + hot_path: + policy: + - "plugin(rate_limiter)" + plugins: + rate_limiter: + config: + max_requests: 10 + on_error: ignore +"#; + let cfg = compile_config(yaml).unwrap(); + let route = cfg.routes.get("hot_path").unwrap(); + let ovr = route.plugin_overrides.get("rate_limiter").unwrap(); + assert_eq!(ovr.on_error.as_deref(), Some("ignore")); + let cfg_yaml = ovr.config.as_ref().unwrap(); + assert_eq!(cfg_yaml["max_requests"], serde_yaml::from_str::("10").unwrap()); + + // Verify EffectivePlugin::resolve sees the override. + let eff = crate::plugin_decl::EffectivePlugin::resolve( + "rate_limiter", + &cfg.plugins, + &route.plugin_overrides, + ) + .unwrap(); + assert_eq!(eff.on_error, Some("ignore")); + // Hooks NOT overridable — still from the global declaration. + assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]); + } + + // ----- compile_policy_block_value (single-block compiler for visitors) ----- + + #[test] + fn compile_policy_block_value_parses_apl_body() { + let yaml = r#" +policy: + - "require(authenticated)" +result: + ssn: "redact(!perm.view_ssn)" +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let compiled = + compile_policy_block_value("global.policy.all", &value).expect("compile block"); + assert_eq!(compiled.route_key, "global.policy.all"); + assert_eq!(compiled.policy.len(), 1); + assert_eq!(compiled.result.len(), 1); + assert_eq!(compiled.result[0].field, "ssn"); + } + + #[test] + fn compile_policy_block_value_null_is_empty_route() { + let value = serde_yaml::Value::Null; + let compiled = + compile_policy_block_value("global.defaults.tool", &value).expect("compile null"); + assert!(compiled.declared_phases().is_empty()); + assert_eq!(compiled.route_key, "global.defaults.tool"); + } + + #[test] + fn compile_policy_block_value_threads_source_into_rule_paths() { + let yaml = r#" +policy: + - "require(authenticated)" +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let compiled = + compile_policy_block_value("global.policies.hr", &value).expect("compile"); + match &compiled.policy[0] { + crate::rules::Effect::When { source, .. } => { + assert_eq!(source, "global.policies.hr.policy[0]"); + } + other => panic!("expected When, got {:?}", other), + } + } + + // ----- delegate: step parsing ----- + + #[test] + fn parse_delegate_step_with_only_plugin() { + let yaml = r#" +- delegate: + plugin: workday-oauth +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate, got {step:?}"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + assert!(ds.config_override.is_none()); + assert!(ds.on_error.is_none()); + assert_eq!(ds.source, "test.policy[0]"); + } + + #[test] + fn parse_delegate_step_with_config_and_on_error() { + let yaml = r#" +- delegate: + plugin: workday-oauth + config: + target: workday-api + permissions: [read_compensation] + on_error: deny +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[1]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate, got {step:?}"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + assert_eq!(ds.on_error.as_deref(), Some("deny")); + let cfg = ds.config_override.as_ref().expect("config_override set"); + let target = cfg + .as_mapping() + .and_then(|m| m.get(serde_yaml::Value::String("target".into()))) + .and_then(|v| v.as_str()); + assert_eq!(target, Some("workday-api")); + } + + #[test] + fn parse_delegate_step_missing_plugin_errors() { + let yaml = r#" +- delegate: + config: { target: workday-api } +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("missing plugin"); + let msg = format!("{err}"); + assert!(msg.contains("requires `plugin:"), "got: {msg}"); + } + + #[test] + fn parse_delegate_step_empty_plugin_errors() { + let yaml = r#" +- delegate: + plugin: "" +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("empty plugin"); + let msg = format!("{err}"); + assert!(msg.contains("cannot be empty"), "got: {msg}"); + } + + #[test] + fn parse_delegate_step_non_string_on_error_errors() { + let yaml = r#" +- delegate: + plugin: workday-oauth + on_error: 42 +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("non-string on_error"); + let msg = format!("{err}"); + assert!(msg.contains("on_error"), "got: {msg}"); + } + + #[test] + fn parse_delegate_step_non_map_body_errors() { + let yaml = r#" +- delegate: workday-oauth +"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("non-map delegate body"); + let msg = format!("{err}"); + assert!(msg.contains("must be a map"), "got: {msg}"); + } + + // ----- delegate(...) function-call string form ----- + + #[test] + fn parse_delegate_string_bare_plugin_name() { + let yaml = r#"- "delegate(workday-oauth)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate, got {step:?}"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + assert!(ds.config_override.is_none()); + assert!(ds.on_error.is_none()); + assert_eq!(ds.source, "test.policy[0]"); + } + + #[test] + fn parse_delegate_string_with_string_kwargs() { + let yaml = r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate, got {step:?}"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); + assert_eq!( + cfg.get(serde_yaml::Value::String("target".into())) + .and_then(|v| v.as_str()), + Some("workday-api"), + ); + assert_eq!( + cfg.get(serde_yaml::Value::String("audience".into())) + .and_then(|v| v.as_str()), + Some("https://workday.com"), + ); + } + + #[test] + fn parse_delegate_string_with_list_kwarg() { + let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation, write_notes])""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate"); + }; + let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); + let perms = cfg + .get(serde_yaml::Value::String("permissions".into())) + .and_then(|v| v.as_sequence()) + .expect("permissions sequence"); + let names: Vec<&str> = perms.iter().filter_map(|v| v.as_str()).collect(); + assert_eq!(names, vec!["read_compensation", "write_notes"]); + } + + #[test] + fn parse_delegate_string_on_error_pulled_out() { + let yaml = r#"- "delegate(workday-oauth, target: workday-api, on_error: continue)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate"); + }; + assert_eq!(ds.on_error.as_deref(), Some("continue")); + // on_error must NOT also leak into config_override. + let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); + assert!( + cfg.get(serde_yaml::Value::String("on_error".into())).is_none(), + "on_error must not appear in config_override" + ); + } + + #[test] + fn parse_delegate_string_quoted_plugin_name() { + // Quoting the plugin name is harmless — the parser strips + // the wrapping quotes. Useful when the name contains + // characters the bare-ident reader doesn't like. + let yaml = r#"- 'delegate("workday-oauth")'"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate"); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + } + + #[test] + fn parse_delegate_string_quoted_value_preserves_internal_commas() { + let yaml = r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let step = parse_step(entry, "test.policy[0]").expect("parse"); + let crate::step::Step::Delegate(ds) = step else { + panic!("expected Delegate"); + }; + let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap(); + assert_eq!( + cfg.get(serde_yaml::Value::String("audience".into())) + .and_then(|v| v.as_str()), + Some("https://workday.com,backup.workday.com"), + ); + } + + #[test] + fn parse_delegate_string_empty_args_errors() { + let yaml = r#"- "delegate()""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("empty args"); + let msg = format!("{err}"); + assert!(msg.contains("plugin name"), "got: {msg}"); + } + + #[test] + fn parse_delegate_string_plugin_kwarg_rejected() { + // `plugin:` as a kwarg is ambiguous when the plugin name is + // also the positional first arg — reject loudly. + let yaml = r#"- "delegate(workday-oauth, plugin: other-thing)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("plugin kwarg"); + let msg = format!("{err}"); + assert!(msg.contains("positional"), "got: {msg}"); + } + + #[test] + fn parse_delegate_string_kwarg_missing_colon_errors() { + let yaml = r#"- "delegate(workday-oauth, target workday-api)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("missing colon"); + let msg = format!("{err}"); + assert!(msg.contains("key: value"), "got: {msg}"); + } + + #[test] + fn parse_delegate_string_unbalanced_brackets_errors() { + let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation)""#; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let entry = &value.as_sequence().unwrap()[0]; + let err = parse_step(entry, "test.policy[0]").expect_err("unbalanced"); + let msg = format!("{err}"); + assert!(msg.contains("unmatched") || msg.contains("unbalanced"), "got: {msg}"); + } + + #[test] + fn compile_route_mixed_string_and_map_delegate_forms() { + // Both forms coexist in the same policy block — string form + // for the compact case, map form for richer config. + let yaml = r#" +routes: + get_compensation: + policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - delegate: + plugin: audit-receipt + on_error: continue + config: + mode: trace +"#; + let cfg = compile_config(yaml).expect("compile"); + let route = cfg.routes.get("get_compensation").expect("route"); + assert_eq!(route.policy.len(), 3); + + // Step [1] is the string-form delegate. + let crate::rules::Effect::Delegate(s1) = &route.policy[1] else { + panic!("expected Delegate at policy[1]"); + }; + assert_eq!(s1.plugin_name, "workday-oauth"); + assert!(s1.on_error.is_none()); + + // Step [2] is the map-form delegate. + let crate::rules::Effect::Delegate(s2) = &route.policy[2] else { + panic!("expected Delegate at policy[2]"); + }; + assert_eq!(s2.plugin_name, "audit-receipt"); + assert_eq!(s2.on_error.as_deref(), Some("continue")); + } + + #[test] + fn compile_route_with_delegate_in_policy_and_post_policy() { + // End-to-end: delegate() lands in the right phase with the + // right source path for diagnostics. Mixed with normal rules + // to prove it doesn't perturb existing step parsing. + let yaml = r#" +routes: + get_compensation: + policy: + - "require(role.hr)" + - delegate: + plugin: workday-oauth + config: + target: workday-api + permissions: [read_compensation] + - "require(authenticated)" + post_policy: + - delegate: + plugin: audit-biscuit + on_error: continue +"#; + let cfg = compile_config(yaml).expect("compile"); + let route = cfg.routes.get("get_compensation").expect("route present"); + assert_eq!(route.policy.len(), 3); + + // Policy step [1] is the delegate. + let crate::rules::Effect::Delegate(ds) = &route.policy[1] else { + panic!("expected Delegate at policy[1], got {:?}", route.policy[1]); + }; + assert_eq!(ds.plugin_name, "workday-oauth"); + assert_eq!(ds.source, "get_compensation.policy[1]"); + + // post_policy[0] is the audit-biscuit delegate. + let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else { + panic!("expected Delegate at post_policy[0]"); + }; + assert_eq!(post_ds.plugin_name, "audit-biscuit"); + assert_eq!(post_ds.on_error.as_deref(), Some("continue")); + assert_eq!(post_ds.source, "get_compensation.post_policy[0]"); + } + + // ----- validate(name) compile-time rejection (DSL spec §4.2) ----- + + #[test] + fn parse_pipeline_rejects_validate_stage_at_compile_time() { + // Named-validator dispatch isn't implemented; the parser + // rejects `validate(...)` rather than letting it through to + // a runtime stub that silently passes. Diagnostic points the + // operator at the working alternatives. + let err = parse_pipeline("str | validate(ssn_format) | mask(4)") + .expect_err("validate(name) should fail to parse"); + let msg = format!("{err}"); + assert!( + msg.contains("not implemented"), + "diagnostic should explain that validate is unimplemented: {msg}", + ); + assert!( + msg.contains("regex") && msg.contains("plugin"), + "diagnostic should suggest regex(...) and plugin(...): {msg}", + ); + assert!( + msg.contains("ssn_format"), + "diagnostic should echo the rejected validator name: {msg}", + ); + } + + #[test] + fn parse_pipeline_does_not_reject_other_stages() { + // Sanity: the validate rejection doesn't catch unrelated + // stages. A pipeline with no validate stage parses cleanly. + let p = parse_pipeline("str | len(..100) | regex(\"^[A-Z]+$\") | mask(4)") + .expect("non-validate pipeline parses"); + assert_eq!(p.stages.len(), 4); + } +} diff --git a/crates/apl-core/src/pipeline.rs b/crates/apl-core/src/pipeline.rs new file mode 100644 index 00000000..0eae7b1c --- /dev/null +++ b/crates/apl-core/src/pipeline.rs @@ -0,0 +1,134 @@ +// Location: ./crates/apl-core/src/pipeline.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Pipe-chain IR for APL `args:` and `result:` phases. +// +// A field-level pipeline is a sequence of `Stage`s separated by `|` in the +// DSL. Validators (str/int/range/...) check the field's value and can fail +// the request; transforms (mask/redact/omit/hash) modify the value; effects +// (taint) record side information. +// +// Grounded in apl-dsl-spec.md §4. +// +// Stages whose evaluator behavior is deferred to step 5c (taint dispatch, +// plugin invocation, regex/named validators, scan placeholders) are still +// represented in the IR so the parser can produce them — the evaluator +// recognizes them and returns a clear "deferred" signal rather than crashing. + +use serde::{Deserialize, Serialize}; + +use crate::rules::Expression; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TypeCheck { + Str, + Int, + Bool, + Float, + Email, + Url, + Uuid, +} + +/// Scope at which a taint applies. Marked `#[non_exhaustive]` so new +/// variants (e.g. `Request`, `Pipeline`, conversation-level) can be +/// added without breaking downstream exhaustive matches. v0 emits only +/// `Session` and `Message`; plugin-extracted taints (from +/// `extensions.security.labels` diffs in `CmfPluginInvoker`) default to +/// `Session` because cpex-core's label monotonicity is session-semantic. +/// Config-side `Step::Taint`/`Stage::Taint` declares scopes explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum TaintScope { + Session, + Message, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScanKind { + PiiRedact, + PiiDetect, + InjectionScan, +} + +/// One stage in a pipe chain. +/// +/// Stages execute left-to-right against a single field value. Validators +/// halt the pipeline on failure; transforms produce a new value; effects +/// (taint) annotate without changing the value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Stage { + // ----- Validators (halt with deny on failure) ----- + Type(TypeCheck), + /// `regex("pattern")` — parser captures the pattern; evaluator stubbed + /// until we add the `regex` crate dependency. + Regex { pattern: String }, + /// `validate(name)` — named validator dispatch; evaluator stubbed. + Validate { name: String }, + /// `len(..N)`, `len(N..M)`, `len(N..)` — string length bounds. + Length { min: Option, max: Option }, + /// Bare range literal `N..M`, `..M`, `N..`, with optional `k`/`K`/`m`/`M` + /// numeric suffixes. Integer-only per DSL §4.3. + Range { min: Option, max: Option }, + /// `enum(a, b, c)` — value must equal one of the listed strings. + Enum { values: Vec }, + + // ----- Transforms (produce a new value) ----- + /// `mask(N)` — replace all but last N chars with `*`. + Mask { keep_last: usize }, + /// `redact` (unconditional) or `redact(!condition)` (conditional). + /// Replaces value with `[REDACTED]` when condition is true (or always, + /// if no condition). + Redact { condition: Option }, + /// `omit` — drop the field from output entirely. No conditional form + /// per DSL §4.1 — use a policy rule for conditional omit. + Omit, + /// `hash` — replace value with a hash digest. + Hash, + + // ----- Effects (deferred to step 5c — IR captured, eval stubbed) ----- + Taint { label: String, scopes: Vec }, + Plugin { name: String }, + Scan { kind: ScanKind }, +} + +/// Sequence of stages applied to one field's value. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Pipeline { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stages: Vec, +} + +impl Pipeline { + pub fn new() -> Self { Self::default() } + pub fn push(&mut self, stage: Stage) { self.stages.push(stage); } + pub fn is_empty(&self) -> bool { self.stages.is_empty() } +} + +/// Attaches a pipeline to a specific field name in the args or result phase. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldRule { + pub field: String, + pub pipeline: Pipeline, + /// Source location (e.g., `"get_compensation.result.ssn"`) for audit. + pub source: String, +} + +/// A taint label produced as a side effect of running a pipeline. +/// +/// The evaluator accumulates these in `PipelineEvaluation.taints`; the host +/// (apl-cpex) drains them and writes to the actual SessionStore. Same shape +/// as `Stage::Taint`'s fields, but lives at the evaluator boundary because +/// it also carries taints emitted by plugin invocations and scan stages +/// — not just literal `taint(...)` stages. +#[derive(Debug, Clone, PartialEq)] +pub struct TaintEvent { + pub label: String, + pub scopes: Vec, +} diff --git a/crates/apl-core/src/plugin_decl.rs b/crates/apl-core/src/plugin_decl.rs new file mode 100644 index 00000000..1b64fc88 --- /dev/null +++ b/crates/apl-core/src/plugin_decl.rs @@ -0,0 +1,290 @@ +// Location: ./crates/apl-core/src/plugin_decl.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin declarations — the parsed shape of the `plugins:` block in a +// unified-config YAML document, plus the per-route override block and +// the 2-layer resolver that merges them. +// +// Spec: `contextforge-plugins-framework-apl/docs/specs/unified-config-proposal.md`, +// §"Plugin Declaration" (lines 173+) +// §"Route-Level Plugin Config Overrides" (lines 360+) +// +// Layering, per spec: +// - Global declaration (root `plugins:`) — full shape +// - Route-level override (`routes..plugins.

:`) — `config`, +// `capabilities`, `on_error` only; hooks/kind/source NOT overridable +// - `EffectivePlugin::resolve(name, registry, route)` merges them. +// +// v0 enforcement: hooks are read from the resolved view (which equals +// the global view since hooks aren't overridable). Config + capability +// overrides are parsed and stored so they survive in the IR for later +// consumers, but not propagated to dispatch yet — capability gating +// and per-call config-override plumbing are tracked separately in the +// APL implementation memory. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// One entry from the root `plugins:` block. The minimal shape apl-core +/// needs to make routing + dispatch decisions; richer CPEX fields +/// (`source`, `priority`, `mode`, transport blocks, `description`, +/// `version`) are captured opaquely under `extra` so the round-trip +/// preserves them without us modeling every variant for v0. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginDeclaration { + /// Plugin name — referenced from routes by `plugin(name)` and used + /// as the key in [`PluginRegistry`]. + pub name: String, + + /// Implementation kind. Spec defines a closed set (`builtin`, + /// `native`, `wasm`, FQN, `external`, `isolated_venv`, PDP kinds) + /// but we parse as a free string so configs using future kinds + /// the runtime understands aren't rejected at the apl-core layer. + pub kind: String, + + /// CPEX hook names this plugin implements. Invokers pick which + /// hook to dispatch based on this list; v0 uses the first entry, + /// future versions will choose by invocation context (policy vs + /// post_policy vs pipe-chain). + /// + /// Per spec §"Hook dispatch": NOT overridable per-route. + #[serde(default)] + pub hooks: Vec, + + /// Attribute-extension capabilities (`read_subject`, `read_labels`, + /// `append_labels`, `read_headers`, …). The runtime uses these for + /// extension filtering before dispatch. v0: parsed but not yet + /// enforced (capability gating is a separately tracked item). + #[serde(default)] + pub capabilities: Vec, + + /// Opaque per-plugin config. Passed to the plugin verbatim by the + /// CPEX runtime; apl-core doesn't interpret it. + #[serde(default)] + pub config: Option, + + /// `fail | ignore | disable`. Defaults to `fail` per spec when None. + #[serde(default)] + pub on_error: Option, + + /// Catch-all for `source`, `priority`, `mode`, transport blocks, + /// `description`, `version`, etc. Preserved so a future loader can + /// read them without re-parsing the YAML. + #[serde(flatten)] + pub extra: HashMap, +} + +/// Per-route override block — only the spec-overridable keys. Bare +/// key-value pairs are NOT merged into `config` implicitly (spec line +/// 399): "The override object always uses the same keys as a plugin +/// declaration (`config:`, `capabilities:`, `on_error:`); bare +/// key-value pairs are not merged into `config` implicitly." +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginOverride { + #[serde(default)] + pub config: Option, + + #[serde(default)] + pub capabilities: Option>, + + #[serde(default)] + pub on_error: Option, +} + +/// Registry of plugin declarations, keyed by name. Built by the parser +/// from the root `plugins:` block. Type alias — no methods — so callers +/// can wrap it in `Arc<_>` or borrow it directly without ceremony. +pub type PluginRegistry = HashMap; + +/// Plugin shape after layering route-level overrides on top of the +/// global declaration. This is what invokers should consume — calling +/// `EffectivePlugin::resolve` (rather than reading the global directly) +/// ensures future override enforcement lands without re-walking the +/// dispatch sites. +#[derive(Debug, Clone)] +pub struct EffectivePlugin<'a> { + pub name: &'a str, + pub kind: &'a str, + /// NOT overridable per spec — always from the global declaration. + pub hooks: &'a [String], + /// Capabilities: route override wins if present, else global. + /// Borrowed when no override applies; owned (cloned) when override + /// present. Use [`capabilities`] to read regardless. + pub capabilities: CapsView<'a>, + /// Config: route override wins if present, else global. Borrowed + /// directly; callers that need to own it call `.cloned()`. + pub config: Option<&'a serde_yaml::Value>, + /// on_error: route override wins if present, else global. + pub on_error: Option<&'a str>, +} + +/// Internal helper that holds either a borrowed slice from the global +/// declaration or an owned override vec; callers see a slice either way. +#[derive(Debug, Clone)] +pub enum CapsView<'a> { + /// Cheap path — no override; point at the global's slice. + Global(&'a [String]), + /// Override applied — caller-owned copy from the override block. + Override(&'a [String]), +} + +impl<'a> CapsView<'a> { + pub fn as_slice(&self) -> &'a [String] { + match self { + Self::Global(s) | Self::Override(s) => s, + } + } +} + +impl<'a> EffectivePlugin<'a> { + /// Merge a global declaration with a per-route override and return + /// the effective view. Returns `None` if `name` isn't in the + /// registry — caller decides whether that's an error. + /// + /// Spec §"Route-Level Plugin Config Overrides": + /// - Override `config` replaces the global `config` entirely. + /// - Override `capabilities` replaces global capabilities. + /// - Override `on_error` replaces global on_error. + /// - Everything else inherits unchanged from the global. + pub fn resolve( + name: &str, + registry: &'a PluginRegistry, + route_overrides: &'a HashMap, + ) -> Option { + let global = registry.get(name)?; + let ovr = route_overrides.get(name); + + let capabilities = match ovr.and_then(|o| o.capabilities.as_deref()) { + Some(c) => CapsView::Override(c), + None => CapsView::Global(global.capabilities.as_slice()), + }; + let config = ovr + .and_then(|o| o.config.as_ref()) + .or(global.config.as_ref()); + let on_error = ovr + .and_then(|o| o.on_error.as_deref()) + .or(global.on_error.as_deref()); + + Some(Self { + name: global.name.as_str(), + kind: global.kind.as_str(), + hooks: global.hooks.as_slice(), + capabilities, + config, + on_error, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn yaml(s: &str) -> serde_yaml::Value { + serde_yaml::from_str(s).unwrap() + } + + fn registry_with(decl: PluginDeclaration) -> PluginRegistry { + let mut r = PluginRegistry::new(); + r.insert(decl.name.clone(), decl); + r + } + + #[test] + fn resolve_with_no_override_returns_global_values() { + let registry = registry_with(PluginDeclaration { + name: "rate_limiter".into(), + kind: "native".into(), + hooks: vec!["tool_pre_invoke".into()], + capabilities: vec!["read_subject".into()], + config: Some(yaml("max_requests: 100")), + on_error: Some("fail".into()), + extra: HashMap::new(), + }); + let overrides = HashMap::new(); + + let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap(); + assert_eq!(eff.name, "rate_limiter"); + assert_eq!(eff.kind, "native"); + assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]); + assert_eq!(eff.capabilities.as_slice(), &["read_subject".to_string()]); + assert_eq!(eff.on_error, Some("fail")); + assert!(matches!(eff.capabilities, CapsView::Global(_))); + } + + #[test] + fn resolve_with_override_replaces_config_and_capabilities_and_on_error() { + let registry = registry_with(PluginDeclaration { + name: "rate_limiter".into(), + kind: "native".into(), + hooks: vec!["tool_pre_invoke".into()], + capabilities: vec!["read_subject".into()], + config: Some(yaml("max_requests: 100")), + on_error: Some("fail".into()), + extra: HashMap::new(), + }); + let mut overrides = HashMap::new(); + overrides.insert( + "rate_limiter".to_string(), + PluginOverride { + config: Some(yaml("max_requests: 10")), + capabilities: Some(vec!["read_subject".into(), "read_labels".into()]), + on_error: Some("ignore".into()), + }, + ); + + let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap(); + // Hooks NOT overridable — still the global value. + assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]); + // Capabilities/config/on_error — overridden. + assert_eq!( + eff.capabilities.as_slice(), + &["read_subject".to_string(), "read_labels".to_string()] + ); + assert!(matches!(eff.capabilities, CapsView::Override(_))); + assert_eq!(eff.on_error, Some("ignore")); + let cfg = eff.config.expect("config present"); + assert_eq!(cfg["max_requests"], yaml("10")); + } + + #[test] + fn resolve_with_partial_override_only_replaces_present_keys() { + // Per spec line 399: only keys present in the override replace + // inherited values. An override with just `on_error` inherits + // config + capabilities from the global. + let registry = registry_with(PluginDeclaration { + name: "audit".into(), + kind: "native".into(), + hooks: vec!["tool_post_invoke".into()], + capabilities: vec!["read_labels".into()], + config: Some(yaml("log_level: info")), + on_error: Some("ignore".into()), + extra: HashMap::new(), + }); + let mut overrides = HashMap::new(); + overrides.insert( + "audit".to_string(), + PluginOverride { + config: None, + capabilities: None, + on_error: Some("fail".into()), + }, + ); + + let eff = EffectivePlugin::resolve("audit", ®istry, &overrides).unwrap(); + assert_eq!(eff.on_error, Some("fail")); // overridden + assert_eq!(eff.capabilities.as_slice(), &["read_labels".to_string()]); // inherited + let cfg = eff.config.expect("config inherited"); + assert_eq!(cfg["log_level"], yaml("info")); // inherited + } + + #[test] + fn resolve_returns_none_for_unknown_plugin() { + let registry = PluginRegistry::new(); + let overrides = HashMap::new(); + assert!(EffectivePlugin::resolve("missing", ®istry, &overrides).is_none()); + } +} diff --git a/crates/apl-core/src/route.rs b/crates/apl-core/src/route.rs new file mode 100644 index 00000000..30dff509 --- /dev/null +++ b/crates/apl-core/src/route.rs @@ -0,0 +1,739 @@ +// Location: ./crates/apl-core/src/route.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Phase orchestration: runs `args → policy → result → post_policy` against a +// `CompiledRoute` and a mutable payload, returning a unified decision plus +// accumulated taints. +// +// This is the entry point apl-cpex calls into. Each phase has its own +// evaluator (see `evaluator.rs`); this module's job is to drive them in +// the right order with the right transitions (apply field mutations, halt +// on deny, thread taints across phases). +// +// Phase semantics (anchored in apl-dsl-spec.md §3): +// - args: walk field rules; Replace/Omit mutate `payload.args`; Deny halts +// - policy: walk steps; Deny halts +// - result: only runs if `payload.result.is_some()`; same as args +// - post_policy: walks steps; the spec leaves room for "observed only" +// handling, but apl-core surfaces the deny — the host (apl-cpex) chooses +// whether to enforce it +// +// Missing fields are skipped silently — a pipeline can't transform what +// isn't there. If a route needs to require presence, that's a policy-phase +// `require(exists(args.X))` rule. + +use std::sync::Arc; + +use crate::attributes::AttributeBag; +use crate::evaluator::{evaluate_pipeline, evaluate_effects, Decision, FieldOutcome}; +use crate::pipeline::TaintEvent; +use crate::rules::CompiledRoute; +use crate::step::{DelegationInvoker, DispatchPhase, PdpResolver, PluginInvoker}; + +/// Mutable payload for a route invocation. `args` is the request arguments +/// object; `result` is the response object (`None` on the inbound path, +/// `Some` once the tool/resource has produced a value). +#[derive(Debug, Clone)] +pub struct RoutePayload { + pub args: serde_json::Value, + pub result: Option, +} + +impl RoutePayload { + pub fn new(args: serde_json::Value) -> Self { + Self { args, result: None } + } + + pub fn with_result(args: serde_json::Value, result: serde_json::Value) -> Self { + Self { args, result: Some(result) } + } +} + +/// Full outcome of running all four phases for a route. +#[derive(Debug, Clone)] +pub struct RouteDecision { + pub decision: Decision, + /// Taints accumulated from any phase. Empty unless a pipeline emitted them. + pub taints: Vec, + /// True if any args field was rewritten or omitted. + pub args_modified: bool, + /// True if any result field was rewritten or omitted. + pub result_modified: bool, +} + +/// Run the **pre-invocation** phases: `args` then `policy`. Used by +/// orchestrators bound to a `tool_pre_invoke`-style hook — by the time +/// post-invoke fires, the tool has produced a response, so result/ +/// post_policy belong to [`evaluate_post`]. +/// +/// On a phase Deny, halts and returns immediately. `args_modified` is +/// set if any args field was rewritten or omitted; `result_modified` is +/// always `false` (post hasn't run). Taints emitted during args/policy +/// land in the returned `taints` vec — survive even on a Deny so audit +/// sees what fired before the halt. +pub async fn evaluate_pre( + route: &CompiledRoute, + bag: &mut AttributeBag, + payload: &mut RoutePayload, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, +) -> RouteDecision { + let mut taints: Vec = Vec::new(); + let mut args_modified = false; + + // ----- args ----- + for rule in &route.args { + let Some(current) = get_dotted(&payload.args, &rule.field).cloned() else { + continue; // missing field → no pipeline to run + }; + let eval = evaluate_pipeline( + &rule.pipeline, + ¤t, + bag, + plugins, + &rule.field, + DispatchPhase::Pre, + ) + .await; + taints.extend(eval.taints); + match eval.outcome { + FieldOutcome::Pass => {} + FieldOutcome::Replace(new_val) => { + if set_dotted(&mut payload.args, &rule.field, new_val) { + args_modified = true; + } + } + FieldOutcome::Omit => { + if remove_dotted(&mut payload.args, &rule.field) { + args_modified = true; + } + } + FieldOutcome::Deny { reason, stage_index: _ } => { + return RouteDecision { + decision: Decision::Deny { + reason: Some(reason), + rule_source: rule.source.clone(), + }, + taints, + args_modified, + result_modified: false, + }; + } + } + } + + // ----- policy ----- + let policy_eval = evaluate_effects( + &route.policy, + bag, + pdp, + plugins, + delegations, + DispatchPhase::Pre, + payload, + ) + .await; + // FieldOps inside `do:` may have rewritten args during policy — + // surface that to the host the same way as an `args:` pipeline. + args_modified |= policy_eval.args_modified; + taints.extend(policy_eval.taints); + RouteDecision { + decision: policy_eval.decision, + taints, + args_modified, + result_modified: false, + } +} + +/// Run the **post-invocation** phases: `result` (if a response payload +/// is present) then `post_policy`. Used by orchestrators bound to a +/// `tool_post_invoke`-style hook. +/// +/// On a phase Deny, halts. `result_modified` is set if any result field +/// was rewritten or omitted; `args_modified` is always `false` (this +/// function doesn't touch args). +pub async fn evaluate_post( + route: &CompiledRoute, + bag: &mut AttributeBag, + payload: &mut RoutePayload, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, +) -> RouteDecision { + let mut taints: Vec = Vec::new(); + let mut result_modified = false; + + // ----- result (only when a response payload is present) ----- + if let Some(result) = payload.result.as_mut() { + for rule in &route.result { + let Some(current) = get_dotted(result, &rule.field).cloned() else { + continue; + }; + let eval = evaluate_pipeline( + &rule.pipeline, + ¤t, + bag, + plugins, + &rule.field, + DispatchPhase::Post, + ) + .await; + taints.extend(eval.taints); + match eval.outcome { + FieldOutcome::Pass => {} + FieldOutcome::Replace(new_val) => { + if set_dotted(result, &rule.field, new_val) { + result_modified = true; + } + } + FieldOutcome::Omit => { + if remove_dotted(result, &rule.field) { + result_modified = true; + } + } + FieldOutcome::Deny { reason, stage_index: _ } => { + return RouteDecision { + decision: Decision::Deny { + reason: Some(reason), + rule_source: rule.source.clone(), + }, + taints, + args_modified: false, + result_modified, + }; + } + } + } + } + + // ----- post_policy ----- + let post_eval = evaluate_effects( + &route.post_policy, + bag, + pdp, + plugins, + delegations, + DispatchPhase::Post, + payload, + ) + .await; + // Same reason as the policy phase: a `do:`-embedded FieldOp may + // have rewritten result fields during post_policy. + result_modified |= post_eval.result_modified; + taints.extend(post_eval.taints); + + RouteDecision { + decision: post_eval.decision, + taints, + args_modified: false, + result_modified, + } +} + +/// Run all four phases against `payload`, mutating it in place. +/// Convenience wrapper for callers that don't need the pre/post split +/// (tests, single-hook hosts). Calls [`evaluate_pre`] then [`evaluate_post`], +/// skipping post entirely on a pre-side Deny. Taints from both halves +/// concatenate; `args_modified` and `result_modified` carry their +/// respective flags independently. +/// +/// Orchestrators that need to fire on distinct pre/post hooks should +/// call [`evaluate_pre`] and [`evaluate_post`] separately so the post +/// half sees the payload after the tool has produced its response. +pub async fn evaluate_route( + route: &CompiledRoute, + bag: &mut AttributeBag, + payload: &mut RoutePayload, + pdp: &Arc, + plugins: &Arc, + delegations: &Arc, +) -> RouteDecision { + let pre = evaluate_pre(route, bag, payload, pdp, plugins, delegations).await; + if matches!(pre.decision, Decision::Deny { .. }) { + return pre; + } + let post = evaluate_post(route, bag, payload, pdp, plugins, delegations).await; + let mut taints = pre.taints; + taints.extend(post.taints); + RouteDecision { + decision: post.decision, + taints, + args_modified: pre.args_modified, + result_modified: post.result_modified, + } +} + +// ===================================================================== +// Dotted-path JSON helpers +// ===================================================================== + +/// Read `root.a.b.c` from a JSON value via dot-separated path. Returns +/// `None` if any segment is missing or the path crosses a non-object. +pub(crate) fn get_dotted<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { + let mut cur = root; + for seg in path.split('.') { + cur = cur.get(seg)?; + } + Some(cur) +} + +/// Write to `root.a.b.c` via dot-separated path. Returns true on success; +/// false if the parent path doesn't exist or doesn't resolve to an object. +/// Does not create missing parent objects — that'd hide schema bugs. +pub(crate) fn set_dotted(root: &mut serde_json::Value, path: &str, value: serde_json::Value) -> bool { + let parts: Vec<&str> = path.split('.').collect(); + let (leaf, parents) = match parts.split_last() { + Some(x) => x, + None => return false, + }; + let mut cur = root; + for seg in parents { + let Some(next) = cur.get_mut(*seg) else { return false; }; + if !next.is_object() { return false; } + cur = next; + } + if let serde_json::Value::Object(map) = cur { + map.insert((*leaf).to_string(), value); + true + } else { + false + } +} + +/// Remove `root.a.b.c` from a JSON value. Returns true if removal happened. +pub(crate) fn remove_dotted(root: &mut serde_json::Value, path: &str) -> bool { + let parts: Vec<&str> = path.split('.').collect(); + let (leaf, parents) = match parts.split_last() { + Some(x) => x, + None => return false, + }; + let mut cur = root; + for seg in parents { + let Some(next) = cur.get_mut(*seg) else { return false; }; + if !next.is_object() { return false; } + cur = next; + } + if let serde_json::Value::Object(map) = cur { + map.remove(*leaf).is_some() + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::{FieldRule, Pipeline, Stage, TaintScope, TypeCheck}; + use crate::rules::{Effect, Expression, Rule}; + use crate::step::{ + NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PluginError, + PluginInvocation, PluginOutcome, + }; + use async_trait::async_trait; + use serde_json::json; + + // ----- Fixtures ----- + + struct AllowPdp; + #[async_trait] + impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { decision: Decision::Allow, diagnostics: vec![] }) + } + } + + struct NoPlugins; + #[async_trait] + impl PluginInvoker for NoPlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + Err(PluginError::NotFound(name.into())) + } + } + + // `evaluate_route` takes `&Arc` / `&Arc` + // so the path through `dispatch_parallel` can `Arc::clone` into each + // spawned branch. These helpers wrap the no-op test stubs once per call. + fn pdp_arc() -> Arc { + Arc::new(AllowPdp) + } + fn plugins() -> Arc { + Arc::new(NoPlugins) + } + fn delegations() -> Arc { + Arc::new(NoopDelegationInvoker) + } + + fn field_rule(field: &str, stages: Vec) -> FieldRule { + FieldRule { + field: field.into(), + pipeline: Pipeline { stages }, + source: format!("test.{}", field), + } + } + + fn deny_rule(source: &str, reason: &str) -> Rule { + Rule::single( + Expression::Always, + Effect::Deny { reason: Some(reason.into()), code: None }, + source, + ) + } + + // ----- Tests ----- + + #[tokio::test] + async fn empty_route_allows() { + let route = CompiledRoute::new("noop"); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({})); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.args_modified); + assert!(!r.result_modified); + assert!(r.taints.is_empty()); + } + + #[tokio::test] + async fn args_pipeline_mutates_payload() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ "ssn": "123-45-6789" })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified); + assert_eq!(payload.args["ssn"], json!("*******6789")); + } + + #[tokio::test] + async fn args_deny_halts_route() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule( + "amount", + vec![ + Stage::Type(TypeCheck::Int), + Stage::Range { min: Some(0), max: Some(100) }, + ], + )); + // Also has a policy rule that would deny — should NOT be reached + // (args deny short-circuits). If reached, source would be "policy[0]" + // instead of the args rule's source. + route.policy.push(Effect::from(deny_rule("policy[0]", "policy denied too"))); + + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ "amount": 200 })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("amount"), "expected args rule source, got {}", rule_source); + } + d => panic!("expected Deny from args phase, got {:?}", d), + } + } + + #[tokio::test] + async fn args_missing_field_is_skipped() { + // Pipeline references `compensation`, payload doesn't have it → + // missing-field rule is skipped silently, route allows. + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule("compensation", vec![Stage::Type(TypeCheck::Int)])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ "other_field": 5 })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.args_modified); + } + + #[tokio::test] + async fn args_omit_drops_field() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule("secret", vec![Stage::Omit])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ "secret": "xyz", "keep": 1 })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified); + assert!(payload.args.get("secret").is_none()); + assert_eq!(payload.args["keep"], json!(1)); + } + + #[tokio::test] + async fn policy_deny_halts_before_result() { + let mut route = CompiledRoute::new("ping"); + route.policy.push(Effect::from(deny_rule("policy[0]", "blocked"))); + // Result rule should never run. + route.result.push(field_rule("ssn", vec![Stage::Redact { condition: None }])); + + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "policy[0]"), + d => panic!("expected policy deny, got {:?}", d), + } + assert!(!r.result_modified); + // Result payload not mutated — redact didn't run. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("123")); + } + + #[tokio::test] + async fn result_phase_skipped_when_no_response() { + let mut route = CompiledRoute::new("ping"); + route.result.push(field_rule("ssn", vec![Stage::Redact { condition: None }])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({})); // no result + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.result_modified); + } + + #[tokio::test] + async fn result_pipeline_redacts_field() { + let mut route = CompiledRoute::new("ping"); + route.result.push(field_rule("ssn", vec![Stage::Redact { condition: None }])); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::with_result( + json!({}), + json!({ "ssn": "123-45-6789", "name": "alice" }), + ); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.result_modified); + let result = payload.result.as_ref().unwrap(); + assert_eq!(result["ssn"], json!("[REDACTED]")); + assert_eq!(result["name"], json!("alice")); + } + + #[tokio::test] + async fn taints_accumulate_across_phases() { + let mut route = CompiledRoute::new("ping"); + // args emits a taint + route.args.push(field_rule( + "input", + vec![Stage::Taint { label: "args_seen".into(), scopes: vec![TaintScope::Session] }], + )); + // result emits a different taint + route.result.push(field_rule( + "output", + vec![Stage::Taint { label: "result_seen".into(), scopes: vec![TaintScope::Message] }], + )); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::with_result( + json!({ "input": "hello" }), + json!({ "output": "world" }), + ); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + let labels: Vec<&str> = r.taints.iter().map(|t| t.label.as_str()).collect(); + assert_eq!(labels, vec!["args_seen", "result_seen"]); + } + + #[tokio::test] + async fn nested_field_path_resolves_and_writes() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule( + "user.profile.ssn", + vec![Stage::Mask { keep_last: 4 }], + )); + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::new(json!({ + "user": { "profile": { "ssn": "123-45-6789", "name": "alice" } } + })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified); + assert_eq!(payload.args["user"]["profile"]["ssn"], json!("*******6789")); + assert_eq!(payload.args["user"]["profile"]["name"], json!("alice")); + } + + #[tokio::test] + async fn nested_field_missing_intermediate_is_skipped() { + let mut route = CompiledRoute::new("ping"); + route.args.push(field_rule("user.profile.ssn", vec![Stage::Mask { keep_last: 4 }])); + let mut bag = AttributeBag::new(); + // `profile` segment is missing → get_dotted returns None → skip. + let mut payload = RoutePayload::new(json!({ "user": { "name": "alice" } })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.args_modified); + } + + #[tokio::test] + async fn post_policy_runs_after_result() { + let mut route = CompiledRoute::new("ping"); + // Result mutates a field, then post_policy denies. + route.result.push(field_rule("ssn", vec![Stage::Redact { condition: None }])); + route.post_policy.push(Effect::from(deny_rule("post_policy[0]", "after-the-fact"))); + + let mut bag = AttributeBag::new(); + let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" })); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "post_policy[0]"), + d => panic!("expected post_policy deny, got {:?}", d), + } + // Result was still mutated before the post_policy deny fired. + assert!(r.result_modified); + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]")); + } + + // ----- Helper unit tests ----- + + #[test] + fn dotted_get_simple_and_nested() { + let v = json!({ "a": { "b": { "c": 7 } } }); + assert_eq!(get_dotted(&v, "a.b.c"), Some(&json!(7))); + assert_eq!(get_dotted(&v, "a.b"), Some(&json!({ "c": 7 }))); + assert!(get_dotted(&v, "a.b.x").is_none()); + assert!(get_dotted(&v, "missing").is_none()); + } + + #[test] + fn dotted_set_overwrites_leaf() { + let mut v = json!({ "a": { "b": 1 } }); + assert!(set_dotted(&mut v, "a.b", json!(99))); + assert_eq!(v["a"]["b"], json!(99)); + } + + #[test] + fn dotted_set_does_not_create_missing_parents() { + // Strict: if `a.b` parent doesn't exist, set fails (no auto-vivify). + let mut v = json!({}); + assert!(!set_dotted(&mut v, "a.b", json!(1))); + assert_eq!(v, json!({})); + } + + #[test] + fn dotted_remove_leaf() { + let mut v = json!({ "a": { "b": 1, "c": 2 } }); + assert!(remove_dotted(&mut v, "a.b")); + assert_eq!(v, json!({ "a": { "c": 2 } })); + // Removing a missing leaf returns false. + assert!(!remove_dotted(&mut v, "a.b")); + } + + // ----- evaluate_pre / evaluate_post (phase split) ----- + + #[tokio::test] + async fn evaluate_pre_runs_args_and_policy_only() { + // Route with both args validators + result transforms. evaluate_pre + // should run args (mutating payload.args), policy (allow here), + // but NOT result — payload.result stays exactly as given. + let mut route = CompiledRoute::new("test"); + route.args.push(field_rule("id", vec![ + Stage::Mask { keep_last: 2 }, + ])); + route.result.push(field_rule("ssn", vec![ + Stage::Redact { condition: None }, + ])); + + let mut payload = RoutePayload::with_result( + json!({ "id": "ABCDEFGH" }), + json!({ "ssn": "555-12-3456" }), + ); + let mut bag = AttributeBag::new(); + let r = evaluate_pre(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified, "args mask stage should have rewritten the field"); + assert!(!r.result_modified, "evaluate_pre must not touch result"); + // Args was rewritten by mask(2). + assert_eq!(payload.args["id"], json!("******GH")); + // Result is untouched — post hasn't run. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("555-12-3456")); + } + + #[tokio::test] + async fn evaluate_post_runs_result_and_post_policy_only() { + // Route with args + result. evaluate_post skips args entirely + // (no mutation), runs result + post_policy. + let mut route = CompiledRoute::new("test"); + route.args.push(field_rule("id", vec![ + Stage::Mask { keep_last: 2 }, + ])); + route.result.push(field_rule("ssn", vec![ + Stage::Redact { condition: None }, + ])); + + let mut payload = RoutePayload::with_result( + json!({ "id": "ABCDEFGH" }), + json!({ "ssn": "555-12-3456" }), + ); + let mut bag = AttributeBag::new(); + let r = evaluate_post(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.args_modified, "evaluate_post must not touch args"); + assert!(r.result_modified, "result redact should have fired"); + // Args is untouched by evaluate_post. + assert_eq!(payload.args["id"], json!("ABCDEFGH")); + // Result was redacted. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]")); + } + + #[tokio::test] + async fn evaluate_pre_deny_halts_before_policy() { + // Args has a type validator that fails → pre denies before policy runs. + let mut route = CompiledRoute::new("test"); + route.args.push(field_rule("id", vec![Stage::Type(TypeCheck::Uuid)])); + // Policy that would always deny if it ran — assert it doesn't. + route.policy.push(Effect::from(Rule::single( + Expression::Always, + Effect::Deny { reason: Some("policy_should_not_run".into()), code: None }, + "test.policy[0]", + ))); + + let mut payload = RoutePayload::new(json!({ "id": "not-a-uuid" })); + let mut bag = AttributeBag::new(); + let r = evaluate_pre(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("test.id"), "args denial got source {}", rule_source); + } + d => panic!("expected args-side Deny, got {:?}", d), + } + } + + #[tokio::test] + async fn evaluate_route_skips_post_on_pre_deny() { + // Wrapper preserves "deny halts before post" — proves the + // refactor didn't regress evaluate_route's semantics. + let mut route = CompiledRoute::new("test"); + route.policy.push(Effect::from(Rule::single( + Expression::Always, + Effect::Deny { reason: Some("policy_deny".into()), code: None }, + "test.policy[0]", + ))); + route.result.push(field_rule("ssn", vec![ + Stage::Redact { condition: None }, + ])); + route.post_policy.push(Effect::Taint { + label: "should_not_emit".into(), + scopes: vec![TaintScope::Session], + }); + + let mut payload = RoutePayload::with_result( + json!({}), + json!({ "ssn": "555-12-3456" }), + ); + let mut bag = AttributeBag::new(); + let r = evaluate_route(&route, &mut bag, &mut payload, &pdp_arc(), &plugins(), &delegations()).await; + assert!(matches!(r.decision, Decision::Deny { .. })); + assert!(!r.result_modified, "post must be skipped on pre-side Deny"); + // post_policy never ran, so its taint never landed. + assert!(r.taints.is_empty()); + // Result untouched. + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("555-12-3456")); + } +} diff --git a/crates/apl-core/src/rules.rs b/crates/apl-core/src/rules.rs new file mode 100644 index 00000000..7fd707aa --- /dev/null +++ b/crates/apl-core/src/rules.rs @@ -0,0 +1,840 @@ +// Location: ./crates/apl-core/src/rules.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// APL intermediate representation. +// +// The compiler (later) produces a `CompiledRoute` per route_key from +// YAML / database / any other ConfigSource. The evaluator (later) +// consumes the IR plus an AttributeBag and returns a decision. +// +// IR types are kept small and pure-data — no dependencies on cpex-core +// extensions, no evaluation logic. See docs/specs/apl-design.md §7. + +use serde::{Deserialize, Serialize}; + +/// Comparison operators in DSL predicates. +/// +/// `In` / `NotIn` are intentionally absent: the DSL spec §2.4 has them as +/// `value_key in set_key` — both sides are attribute references, not a +/// key-vs-literal shape. They'll land as a dedicated `Condition` variant +/// when the parser arrives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompareOp { + Eq, + NotEq, + Gt, + GtEq, + Lt, + LtEq, + /// ` contains ` — left is a StringSet attribute, + /// right is a string literal. + Contains, +} + +/// Right-hand side of a comparison. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Literal { + Bool(bool), + Int(i64), + Float(f64), + String(String), +} + +impl From for Literal { fn from(v: bool) -> Self { Literal::Bool(v) } } +impl From for Literal { fn from(v: i64) -> Self { Literal::Int(v) } } +impl From for Literal { fn from(v: f64) -> Self { Literal::Float(v) } } +impl From<&str> for Literal { fn from(v: &str) -> Self { Literal::String(v.to_string()) } } +impl From for Literal { fn from(v: String) -> Self { Literal::String(v) } } + +/// Leaf predicate. +/// +/// `Comparison` covers `key op value`. The truthiness checks are split out +/// (`IsTrue` / `IsFalse`) because they're the most common form — `authenticated`, +/// `role.hr`, `delegated`. +/// +/// The DSL's `require(...)` keyword is **not** represented here — it's a +/// rule-level shorthand for "deny when the condition fails," and the parser +/// desugars it into `Not` / `And` / `Or` over `IsFalse` expressions plus +/// an `Action::Deny`. See DSL spec §8.1 desugarings. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Condition { + Comparison { key: String, op: CompareOp, value: Literal }, + IsTrue { key: String }, + IsFalse { key: String }, + /// DSL `exists(key)` — true iff the key is present in the + /// AttributeBag, regardless of its value. Distinct from `IsTrue` + /// (which only succeeds for truthy values). Per DSL §2.2. + Exists { key: String }, + /// DSL `value_key in set_key` (negate=false) / `value_key not in set_key` + /// (negate=true). Both operands are attribute keys, not literals — the + /// scalar at `value_key` is checked for membership in the StringSet at + /// `set_key`. Per DSL §2.4. Returns `false` if either key is missing or + /// the types don't match (scalar must resolve to a string). + InSet { value_key: String, set_key: String, negate: bool }, +} + +/// Compound predicate. +/// +/// `Always` is the implicit-true predicate for bare-effect rules +/// (DSL §3.1): `- plugin(rate_limiter)` / `- taint(audit)` / unconditional +/// `- deny` / `- allow`. It's never produced by predicate-string parsing +/// — only by rule-level forms where no `when:` is supplied. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Expression { + Condition(Condition), + And(Vec), + Or(Vec), + Not(Box), + Always, +} + +/// One thing a matching rule does. Mirrors DSL spec §3 effect classes: +/// +/// * Control — `Allow`, `Deny` +/// * Label — `Taint` +/// * Host — `Plugin`, `Delegate` +/// +/// Content effects (`redact`, `mask`, `omit`, `hash`) and orchestration +/// (`Sequential`, `Parallel`) land in later slices (E2 / E3). +/// +/// PDP calls (`cedar:(…)`, `opa(…)`, …) remain top-level [`Step`] +/// variants for now; folding them into `Effect` is an E4 cleanup. +/// +/// # Inside a `Vec` (a rule's `effects` body) +/// +/// * `Allow` is a no-op — lets evaluation continue to the next effect +/// in the list, then to the next step in `policy:`. +/// * `Deny` short-circuits the rest of the list, the rest of the +/// `policy:` block, and the route. The `reason` propagates into +/// the violation message. +/// * `Plugin` / `Delegate` dispatch identically to their top-level +/// `Step` counterparts (same invoker traits). +/// * `Taint` accumulates into the phase's taint events. +/// +/// [`Step`]: crate::step::Step +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Effect { + Allow, + Deny { + reason: Option, + /// Author-supplied stable violation code. When `Some`, it + /// overrides the rule's auto-generated source-position code + /// (`routes.tool:X.apl.policy[N]`) downstream. Useful when + /// MCP clients want to dispatch on category (`quota.exceeded`, + /// `delegation.depth_exceeded`) rather than position, or when + /// multiple routes share a deny category that should + /// aggregate consistently in audit dashboards. When `None`, + /// the evaluator falls back to `rule.source` as the code — + /// matches the historical behavior. + /// + /// Parser shape: `deny('reason', 'code')` (two positional + /// arguments) or the structured `deny: { reason: ..., code: ... }` + /// map form. + code: Option, + }, + Plugin { + name: String, + }, + Delegate(crate::step::DelegateStep), + Taint { + label: String, + scopes: Vec, + }, + /// Content effect (DSL §3) — apply a pipe chain (`redact`, `mask`, + /// `omit`, `hash`, validators, transforms) to a field in the + /// route's args or result. The author writes + /// `result.salary | redact` inside a `do:` body; the parser + /// splits the dotted path from the pipeline. + /// + /// `path` must start with `args.` or `result.` — the evaluator + /// dispatches the lookup against `RoutePayload.args` or + /// `RoutePayload.result`. A FieldOp inside a Pre-phase route's + /// `do:` that targets `result.X` is a no-op (the result hasn't + /// been produced yet); same goes for a Post-phase rule that + /// targets `args.X` (the args are already on the wire). The + /// evaluator silently skips out-of-phase ops so the same + /// `when:`/`do:` shape can describe both phases without + /// branching. + FieldOp { + path: String, + stages: Vec, + }, + /// Run a list of effects in declaration order, stopping on the + /// first Deny. Semantically equivalent to inlining the list into + /// the enclosing scope; the variant exists to make grouping + /// explicit and to pair with `Parallel`. + Sequential(Vec), + /// Run a list of effects concurrently. Any Deny → overall Deny. + /// Taints from all branches accumulate. Bag and payload mutations + /// inside parallel branches are **discarded** when the branch + /// completes — each branch gets a clone of the state, never the + /// shared mutable original. Plugins inside `Parallel` can still + /// emit taints (those merge); any other mutation they try to make + /// (bag writes, args/result rewrites) vanishes. + /// + /// Config-load rejects `FieldOp` and `Delegate` directly inside + /// `Parallel` (recursively), since both would silently drop their + /// effect. The escape valve is `Sequential`. + Parallel(Vec), + /// Predicate-gated body. `body` runs in order when `condition` + /// evaluates to true; any Deny in the body halts the surrounding + /// phase. Replaces the historical `Step::Rule(Rule)` shape — + /// `when:` / `do:` directly desugars to this. A bare `require(X)` + /// or `deny(X)` shorthand compiles to `When { condition: X, + /// body: vec![Effect::Allow / Deny] }`. + /// + /// `source` is the human-readable origin (e.g. `"routes.X.policy[2]"`) + /// surfaced in `Decision::Deny.rule_source` when the body denies + /// without supplying its own code. + When { + condition: Expression, + body: Vec, + source: String, + }, + /// External PDP call. `on_allow` / `on_deny` are reaction effect + /// lists fired against the PDP's decision (DSL §7.5). Replaces + /// `Step::Pdp { ... }` — `args`-shape stays identical. + Pdp { + call: crate::step::PdpCall, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + on_allow: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + on_deny: Vec, + }, +} + +impl Effect { + /// Walk this effect (and any nested effects) checking whether any + /// node would mutate route state. Used by the config-load + /// validator to reject `FieldOp` / `Delegate` inside `Parallel` + /// since both would silently drop their effect in a discarded + /// branch. + pub fn contains_mutation(&self) -> bool { + match self { + Effect::FieldOp { .. } | Effect::Delegate(_) => true, + Effect::Sequential(effects) | Effect::Parallel(effects) => { + effects.iter().any(Effect::contains_mutation) + } + Effect::When { body, .. } => body.iter().any(Effect::contains_mutation), + Effect::Pdp { on_allow, on_deny, .. } => { + on_allow.iter().any(Effect::contains_mutation) + || on_deny.iter().any(Effect::contains_mutation) + } + Effect::Allow + | Effect::Deny { .. } + | Effect::Plugin { .. } + | Effect::Taint { .. } => false, + } + } + + /// Walk the effect tree rejecting any `FieldOp` / `Delegate` that + /// lives directly or transitively under a `Parallel` node. Returns + /// the path string of the first violation found (or `Ok(())` if + /// the tree is clean). Run at config-load. + pub fn validate_parallel_purity(&self) -> Result<(), String> { + match self { + Effect::Parallel(effects) => { + for e in effects { + if e.contains_mutation() { + return Err(format!( + "`parallel:` contains a mutation effect ({:?}); \ + use `sequential:` for ordered mutations", + e + )); + } + // Still validate nested parallels even if this one + // is "clean at the top" — e.g. parallel → sequential + // → parallel(field_op) is still illegal. + e.validate_parallel_purity()?; + } + Ok(()) + } + Effect::Sequential(effects) => { + for e in effects { + e.validate_parallel_purity()?; + } + Ok(()) + } + Effect::When { body, .. } => { + for e in body { + e.validate_parallel_purity()?; + } + Ok(()) + } + Effect::Pdp { on_allow, on_deny, .. } => { + for e in on_allow.iter().chain(on_deny.iter()) { + e.validate_parallel_purity()?; + } + Ok(()) + } + _ => Ok(()), + } + } +} + +/// One compiled rule: a predicate plus the effects to fire when it +/// matches. +/// +/// `effects` is always non-empty for parser-produced rules. The +/// historical "single Allow/Deny" cases are represented by a one-element +/// `Vec` — slightly more allocation than a flat enum, but keeps one +/// dispatch path instead of two and eliminates the ambiguity of having +/// both `Action::Allow` and `Effect::Allow` in the IR. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Rule { + pub condition: Expression, + pub effects: Vec, + /// Human-readable source (original YAML line, file path, etc.). + /// Surfaces in audit logs and policy violation diagnostics. + pub source: String, +} + +impl Rule { + /// Construct a single-effect rule. Convenience for the common + /// `Allow` / `Deny` shapes that don't need a `vec![]` at the + /// call site. + pub fn single(condition: Expression, effect: Effect, source: impl Into) -> Self { + Self { + condition, + effects: vec![effect], + source: source.into(), + } + } +} + +/// `Rule` is structurally identical to `Effect::When`. The From impl lets +/// callers that already hold a `Rule` (notably the parser's inner helpers +/// and the test fixtures) drop a `.into()` instead of re-spelling all +/// three fields. Bridges the few remaining producers while the migration +/// completes; will probably stay long-term because the parser still +/// builds Rule incrementally before deciding it's an Effect::When. +impl From for Effect { + fn from(r: Rule) -> Effect { + Effect::When { + condition: r.condition, + body: r.effects, + source: r.source, + } + } +} + +/// One of the four lifecycle phases the evaluator runs per route. +/// +/// See docs/specs/apl-design.md §3 — the `PolicyEvaluator` trait has one +/// async method per phase. `declared_phases()` lets the host skip phases +/// the route doesn't use. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Args, + Policy, + Result, + PostPolicy, +} + +/// Bit-packed set of phases a route declared. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PhaseSet(u8); + +impl PhaseSet { + pub fn new() -> Self { Self(0) } + + pub fn insert(&mut self, p: Phase) { + self.0 |= Self::bit(p); + } + + pub fn contains(&self, p: Phase) -> bool { + self.0 & Self::bit(p) != 0 + } + + pub fn is_empty(&self) -> bool { self.0 == 0 } + + fn bit(p: Phase) -> u8 { + match p { + Phase::Args => 0b0001, + Phase::Policy => 0b0010, + Phase::Result => 0b0100, + Phase::PostPolicy => 0b1000, + } + } +} + +/// Compiler output for a single route. +/// +/// One `CompiledRoute` per route_key. The compiler merges global / default / +/// tag / route-specific rules from the config hierarchy down into these four +/// phase lists before the evaluator sees them — the IR has no notion of +/// "tag rules" or "route overrides," only "steps that fire in phase P." +/// +/// `args` and `result` are per-field pipelines (validators + transforms). +/// `policy` and `post_policy` are step lists — predicate-and-action rules +/// plus PDP calls, plugin invocations, and taint effects. See +/// apl-dsl-spec §1.2 / §4 / §7. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CompiledRoute { + pub route_key: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub policy: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub result: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub post_policy: Vec, + /// Per-plugin overrides declared on this route's `plugins:` block. + /// Keyed by plugin name; merged at dispatch time via + /// `EffectivePlugin::resolve(name, registry, &this.plugin_overrides)`. + /// Per spec only `config`, `capabilities`, `on_error` are overridable; + /// hooks/kind/source always come from the global declaration. + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub plugin_overrides: std::collections::HashMap, +} + +impl CompiledRoute { + pub fn new(route_key: impl Into) -> Self { + Self { route_key: route_key.into(), ..Default::default() } + } + + /// Which phases this route uses. Empty phases are not declared. + pub fn declared_phases(&self) -> PhaseSet { + let mut set = PhaseSet::new(); + if !self.args.is_empty() { set.insert(Phase::Args); } + if !self.policy.is_empty() { set.insert(Phase::Policy); } + if !self.result.is_empty() { set.insert(Phase::Result); } + if !self.post_policy.is_empty() { set.insert(Phase::PostPolicy); } + set + } + + /// Apply a more-specific policy layer on top of this one. Used by + /// orchestrators (apl-cpex's visitor) to stack the unified-config + /// hierarchy least-to-most-specific: + /// + /// ```text + /// effective = CompiledRoute::default() + /// effective.apply_layer(global_block) + /// effective.apply_layer(default_block) + /// effective.apply_layer(tag_block) + /// effective.apply_layer(route_block) + /// ``` + /// + /// Each call adds the parameter on top of what's already there; + /// `more_specific` wins on collisions because it represents a + /// later/narrower layer in the inheritance chain. + /// + /// Merge semantics: + /// - **`policy` / `post_policy`**: `more_specific`'s steps append + /// *after* self's. Earlier layers run first — globals deny before + /// route-specific rules get a chance. + /// - **`args` / `result`**: per-field; if both layers declare the + /// same field, `more_specific`'s rule replaces self's. Fields + /// only in self stay; fields only in `more_specific` are added. + /// - **`plugin_overrides`**: HashMap merge; `more_specific` wins + /// on key collisions, otherwise prefix's entries fill gaps. + /// + /// `self.route_key` is preserved — apply_layer doesn't overwrite + /// identity, just policy content. + pub fn apply_layer(&mut self, more_specific: CompiledRoute) { + // policy / post_policy: more_specific's steps append AFTER self. + // Order of accumulated calls = order of evaluation. + self.policy.extend(more_specific.policy); + self.post_policy.extend(more_specific.post_policy); + + // args: more_specific wins on field collision — drop any self.args + // entries the new layer redefines, then push the new layer's. + let ms_fields: std::collections::HashSet = + more_specific.args.iter().map(|f| f.field.clone()).collect(); + self.args.retain(|f| !ms_fields.contains(&f.field)); + self.args.extend(more_specific.args); + + // result: same shape as args. + let ms_result_fields: std::collections::HashSet = + more_specific.result.iter().map(|f| f.field.clone()).collect(); + self.result.retain(|f| !ms_result_fields.contains(&f.field)); + self.result.extend(more_specific.result); + + // plugin_overrides: HashMap::extend overwrites on key collision, + // which is exactly the more_specific-wins semantic. + self.plugin_overrides.extend(more_specific.plugin_overrides); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phase_set_basic() { + let mut set = PhaseSet::new(); + assert!(set.is_empty()); + set.insert(Phase::Policy); + set.insert(Phase::Result); + assert!(set.contains(Phase::Policy)); + assert!(set.contains(Phase::Result)); + assert!(!set.contains(Phase::Args)); + assert!(!set.contains(Phase::PostPolicy)); + assert!(!set.is_empty()); + } + + #[test] + fn compiled_route_declared_phases() { + let mut route = CompiledRoute::new("get_compensation"); + assert!(route.declared_phases().is_empty()); + + route.policy.push(Effect::When { + condition: Expression::Condition(Condition::IsTrue { + key: "authenticated".into(), + }), + body: vec![Effect::Allow], + source: "policy[0]".into(), + }); + let phases = route.declared_phases(); + assert!(phases.contains(Phase::Policy)); + assert!(!phases.contains(Phase::Args)); + } + + #[test] + fn literal_from_impls() { + // From impls keep test/builder code readable. + let r = Rule { + condition: Expression::Condition(Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::Gt, + value: 2_i64.into(), + }), + effects: vec![Effect::Deny { reason: Some("too deep".into()), code: None }], + source: "policy[0]".into(), + }; + if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition { + assert_eq!(value, Literal::Int(2)); + } else { + panic!("expected Comparison"); + } + } + + #[test] + fn rule_serde_roundtrip() { + let r = Rule { + condition: Expression::And(vec![ + Expression::Condition(Condition::IsTrue { key: "authenticated".into() }), + Expression::Condition(Condition::Comparison { + key: "delegation.depth".into(), + op: CompareOp::LtEq, + value: 3_i64.into(), + }), + ]), + effects: vec![Effect::Allow], + source: "policy[1]".into(), + }; + let json = serde_json::to_string(&r).unwrap(); + let back: Rule = serde_json::from_str(&json).unwrap(); + // No PartialEq on Rule (would force PartialEq on Action's variants + // with floats etc.); spot-check the discriminator path instead. + assert!(matches!(back.effects.as_slice(), [Effect::Allow])); + assert_eq!(back.source, "policy[1]"); + } + + #[test] + fn compiled_route_serde_skips_empty_phases() { + let route = CompiledRoute::new("ping"); + let json = serde_json::to_string(&route).unwrap(); + // Empty phase vecs should not serialize — keeps audit logs clean. + assert_eq!(json, r#"{"route_key":"ping"}"#); + } + + #[test] + fn apply_layer_appends_policy_and_post_policy_in_evaluation_order() { + // Start with global (least specific), then layer route on top. + // After: global.policy[0] runs first, route.policy[0] runs second. + let mut effective = CompiledRoute::new("route.get_compensation"); + // Seed effective with global content (simulating having already + // applied the global layer once). + effective.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "global.policy[0]".into(), + }); + effective.post_policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "global.post_policy[0]".into(), + }); + + // Now apply the route-specific layer on top. + let mut route_layer = CompiledRoute::new("ignored"); + route_layer.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "route.policy[0]".into(), + }); + route_layer.post_policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "route.post_policy[0]".into(), + }); + + effective.apply_layer(route_layer); + + // global ran first, route ran second — first-deny-wins respects + // the hierarchy. + assert_eq!(effective.policy.len(), 2); + match &effective.policy[0] { + Effect::When { source, .. } => assert_eq!(source, "global.policy[0]"), + _ => panic!(), + } + match &effective.policy[1] { + Effect::When { source, .. } => assert_eq!(source, "route.policy[0]"), + _ => panic!(), + } + assert_eq!(effective.post_policy.len(), 2); + + // route_key preserved (apply_layer doesn't touch identity). + assert_eq!(effective.route_key, "route.get_compensation"); + } + + #[test] + fn apply_layer_args_more_specific_wins_on_field_collision() { + use crate::pipeline::{FieldRule, Pipeline, Stage, TypeCheck}; + + // Start with the default (less specific) layer. + let mut effective = CompiledRoute::new("route.X"); + effective.args.push(FieldRule { + field: "id".into(), + pipeline: Pipeline { stages: vec![Stage::Type(TypeCheck::Str)] }, + source: "default.args.id".into(), + }); + effective.args.push(FieldRule { + field: "trace_id".into(), + pipeline: Pipeline { stages: vec![Stage::Type(TypeCheck::Str)] }, + source: "default.args.trace_id".into(), + }); + + // Layer route (more specific) on top — it redefines `id`. + let mut route_layer = CompiledRoute::new("ignored"); + route_layer.args.push(FieldRule { + field: "id".into(), + pipeline: Pipeline { stages: vec![Stage::Type(TypeCheck::Uuid)] }, + source: "route.args.id".into(), + }); + + effective.apply_layer(route_layer); + + assert_eq!(effective.args.len(), 2); + // `id` is now the route's (Uuid), not the default's (Str). + let id_rule = effective.args.iter().find(|f| f.field == "id").unwrap(); + assert!(matches!(id_rule.pipeline.stages[0], Stage::Type(TypeCheck::Uuid))); + assert_eq!(id_rule.source, "route.args.id"); + // `trace_id` survives from the default — route didn't touch it. + let trace = effective.args.iter().find(|f| f.field == "trace_id").unwrap(); + assert_eq!(trace.source, "default.args.trace_id"); + } + + #[test] + fn apply_layer_plugin_overrides_more_specific_wins() { + use crate::plugin_decl::PluginOverride; + + // Default (less specific) layer. + let mut effective = CompiledRoute::new("route.X"); + effective.plugin_overrides.insert( + "rate_limiter".into(), + PluginOverride { on_error: Some("ignore".into()), ..Default::default() }, + ); + effective.plugin_overrides.insert( + "audit_logger".into(), + PluginOverride { on_error: Some("ignore".into()), ..Default::default() }, + ); + + // Route (more specific) layer overrides rate_limiter. + let mut route_layer = CompiledRoute::new("ignored"); + route_layer.plugin_overrides.insert( + "rate_limiter".into(), + PluginOverride { on_error: Some("fail".into()), ..Default::default() }, + ); + + effective.apply_layer(route_layer); + + assert_eq!(effective.plugin_overrides.len(), 2); + assert_eq!( + effective.plugin_overrides["rate_limiter"].on_error.as_deref(), + Some("fail"), + "route's override wins on collision", + ); + // audit_logger untouched — route didn't redefine it. + assert_eq!( + effective.plugin_overrides["audit_logger"].on_error.as_deref(), + Some("ignore"), + ); + } + + #[test] + fn apply_layer_chained_walks_hierarchy_in_specificity_order() { + // Build effective policy by applying layers least-to-most-specific. + // Mirrors how AplConfigVisitor will compose global/default/tag/route. + let mut effective = CompiledRoute::new("route.get_compensation"); + + let mut global = CompiledRoute::default(); + global.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "global.policy[0]".into(), + }); + + let mut default = CompiledRoute::default(); + default.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "default.policy[0]".into(), + }); + + let mut tag = CompiledRoute::default(); + tag.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "tag.hr.policy[0]".into(), + }); + + let mut route = CompiledRoute::default(); + route.policy.push(Effect::When { + condition: Expression::Always, + body: vec![Effect::Allow], + source: "route.policy[0]".into(), + }); + + effective.apply_layer(global); + effective.apply_layer(default); + effective.apply_layer(tag); + effective.apply_layer(route); + + // Order of calls = order of evaluation. global runs first, + // route runs last (first-deny-wins lets globals deny early). + let sources: Vec<&str> = effective + .policy + .iter() + .map(|s| match s { + Effect::When { source, .. } => source.as_str(), + _ => "", + }) + .collect(); + assert_eq!( + sources, + vec![ + "global.policy[0]", + "default.policy[0]", + "tag.hr.policy[0]", + "route.policy[0]", + ] + ); + } + + // ----- E3: parallel-purity validation ----- + + #[test] + fn validate_parallel_pure_block_passes() { + // A parallel block of read-only effects validates clean. + let effect = Effect::Parallel(vec![ + Effect::Plugin { name: "rate_limiter".into() }, + Effect::Plugin { name: "audit".into() }, + Effect::Allow, + ]); + assert!(effect.validate_parallel_purity().is_ok()); + } + + #[test] + fn validate_parallel_rejects_field_op() { + // FieldOp would silently lose its mutation in a discarded + // branch — config-load surfaces this loudly. + let effect = Effect::Parallel(vec![ + Effect::Plugin { name: "audit".into() }, + Effect::FieldOp { + path: "args.ssn".into(), + stages: vec![], + }, + ]); + let err = effect.validate_parallel_purity().unwrap_err(); + assert!(err.contains("mutation"), "got: {}", err); + assert!(err.contains("FieldOp"), "should name the offender: {}", err); + } + + #[test] + fn validate_parallel_rejects_delegate() { + // Same reason as FieldOp — the minted token would land in a + // bag that gets discarded. + let delegate = Effect::Delegate(crate::step::DelegateStep { + plugin_name: "workday".into(), + config_override: None, + on_error: None, + source: "test".into(), + }); + let effect = Effect::Parallel(vec![Effect::Allow, delegate]); + let err = effect.validate_parallel_purity().unwrap_err(); + assert!(err.contains("mutation")); + } + + #[test] + fn validate_parallel_recurses_into_nested_parallel() { + // `parallel → sequential → parallel(field_op)` — the inner + // parallel still illegal. Recursion must catch it. + let inner_parallel = Effect::Parallel(vec![Effect::FieldOp { + path: "args.x".into(), + stages: vec![], + }]); + let outer = Effect::Parallel(vec![ + Effect::Sequential(vec![Effect::Allow, inner_parallel]), + ]); + assert!(outer.validate_parallel_purity().is_err()); + } + + #[test] + fn validate_top_level_sequential_allows_mutations() { + // FieldOp / Delegate are allowed under Sequential (or at top + // level) — only Parallel rejects them. + let effect = Effect::Sequential(vec![ + Effect::FieldOp { + path: "args.ssn".into(), + stages: vec![], + }, + Effect::Allow, + ]); + assert!(effect.validate_parallel_purity().is_ok()); + } + + #[test] + fn validate_contains_mutation_classifies_each_variant() { + // White-box check on the helper so future Effect additions + // get flagged here when they should be classified. + assert!(!Effect::Allow.contains_mutation()); + assert!(!Effect::Deny { reason: None, code: None }.contains_mutation()); + assert!(!Effect::Plugin { name: "x".into() }.contains_mutation()); + assert!(!Effect::Taint { + label: "x".into(), + scopes: vec![], + } + .contains_mutation()); + + assert!(Effect::FieldOp { + path: "args.x".into(), + stages: vec![], + } + .contains_mutation()); + assert!(Effect::Delegate(crate::step::DelegateStep { + plugin_name: "x".into(), + config_override: None, + on_error: None, + source: "x".into(), + }) + .contains_mutation()); + + // Composite — mutates iff any child mutates. + let pure_seq = Effect::Sequential(vec![Effect::Allow]); + assert!(!pure_seq.contains_mutation()); + let dirty_seq = Effect::Sequential(vec![Effect::FieldOp { + path: "args.x".into(), + stages: vec![], + }]); + assert!(dirty_seq.contains_mutation()); + } +} diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs new file mode 100644 index 00000000..dca1921e --- /dev/null +++ b/crates/apl-core/src/step.rs @@ -0,0 +1,506 @@ +// Location: ./crates/apl-core/src/step.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Policy-phase Step IR and async dispatch traits. +// +// The DSL allows policy:/post_policy: lists to contain three kinds of +// entries beyond predicate-and-action rules: +// +// - PDP calls: `cedar:(...)`, `opa(...)`, `authzen(...)`, `nemo(...)` +// with optional `on_deny:` / `on_allow:` reaction blocks +// - Plugin invocations: `plugin(name)` +// - Taint effects: `taint(label[, scope])` +// +// `Step` is the union over these forms plus the existing `Rule`. The async +// `evaluate_steps` function walks a Step list, dispatching PDP calls via +// `PdpResolver` and plugin calls via `PluginInvoker`. Taint dispatch is +// recognized but no-op in apl-core — actual SessionStore writes happen in +// `apl-cpex`, which has access to that machinery. +// +// Grounded in apl-dsl-spec.md §3 (effects) / §7 (PDP integration) and +// apl-design.md §8.1 (PdpResolver seam). + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::evaluator::Decision; +use crate::pipeline::{TaintEvent, TaintScope}; +use crate::rules::Rule; + +/// Parser-internal intermediate IR. After the parser builds a Step +/// tree, `parser::step_to_top_level_effect` converts it into the +/// unified [`crate::rules::Effect`] used by the evaluator + every +/// public entry point. +/// +/// `Step` exists only because `parse_step` builds its nodes +/// incrementally and the conversion to `Effect::When` / +/// `Effect::Pdp` happens at the top of `compile_apl_blocks` once +/// the source position is known. Not part of the public API as of +/// E4 — external code dispatches on `Effect` everywhere. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Step { + /// Predicate-and-action rule (the existing 5a/5b/5c case). + Rule(Rule), + + /// External PDP call. `on_deny` / `on_allow` are reaction Step lists + /// that fire based on the PDP's decision (DSL §7.5). + Pdp { + call: PdpCall, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + on_deny: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + on_allow: Vec, + }, + + /// `plugin(name)` — invoke a CPEX-registered plugin. The plugin's + /// `PluginResult` decision becomes the step's outcome. + Plugin { name: String }, + + /// `delegate: { plugin: ..., ... }` — mint a downstream delegation + /// token via a TokenDelegateHook plugin. Populates + /// `delegation.granted_*` attributes in the bag so subsequent + /// rules in the same step list can read them. See + /// `docs/apl-identity-delegation-design.md`. + Delegate(DelegateStep), + + /// `taint(label[, scope])` — apply a taint label. Always succeeds; + /// never produces a Deny. SessionStore dispatch happens in apl-cpex. + Taint { label: String, scopes: Vec }, +} + +/// One delegation invocation inside `policy:` or `post_policy:`. +/// +/// At runtime the apl-cpex `DelegationInvoker` constructs a +/// `cpex_core::delegation::DelegationPayload` from +/// * the inbound bearer token (pulled from +/// `Extensions.raw_credentials.inbound_tokens`), +/// * this step's `args` (target / audience / permissions / mode / +/// attenuation, layered over the plugin's configured defaults), +/// * extensions-derived context (subject, prior delegation chain), +/// +/// then calls `manager.invoke_entries::(...)`. On +/// success the resulting `delegated_token` is written into +/// `Extensions.raw_credentials.delegated_tokens.*` and the granted +/// scopes / audience surface as `delegation.granted.*` attributes +/// in the policy bag for downstream rules to inspect. +/// +/// `args` is a free-form map because each delegation backend has its +/// own typed config shape; apl-core treats it as opaque and hands it +/// to the plugin via the existing per-call config-override pathway. +/// +/// # Multiple `delegate(...)` in one phase (most-recent-wins) +/// +/// Multiple `delegate(...)` steps in the same phase are supported — +/// each fires independently, each contributes to `Extensions` +/// (`raw_credentials.delegated_tokens` is a HashMap keyed on +/// audience+scope+mode so tokens accumulate; `delegation.chain` +/// grows with each hop). But the `delegation.granted.*` bag keys +/// are **overwritten** on each call — only the most recent +/// delegate's grants are queryable from downstream `require(...)` +/// rules. +/// +/// For fan-out flows that need multiple independently-queryable +/// grants, split into `policy:` + `post_policy:` or reach for a +/// future per-step `as:` alias (not in v0; see the design doc's +/// "Open design questions" section). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DelegateStep { + /// Plugin name — must reference an entry in the top-level + /// `plugins:` block that registers under the `token.delegate` + /// hook. + pub plugin_name: String, + + /// Per-call config overrides applied for this delegation only. + /// Layered on top of the plugin's default config; the framework's + /// `build_override_entries` plumbing handles the merge. + /// Common keys: `target`, `audience`, `permissions`, `mode`, + /// `attenuation`. Schema is plugin-defined. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_override: Option, + + /// `deny | continue` — what to do when the plugin returns a + /// deny (e.g. IdP refusal, network error). `None` defaults to + /// `"deny"` (fail-closed; matches PDP step semantics). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_error: Option, + + /// Human-readable source path (e.g. + /// `"route.get_compensation.policy[2]"`) — used in audit and + /// `Decision::Deny.rule_source` when the step denies. + pub source: String, +} + +/// A PDP invocation, opaque-args style. Resolvers parse `args` based on +/// the dialect they handle — apl-core doesn't impose a Cedar/OPA/AuthZen +/// schema on `args`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PdpCall { + pub dialect: PdpDialect, + /// Dialect-specific call arguments — typically a map for Cedar + /// (`action`, `resource`, …) or a string for OPA/AuthZen/NeMo + /// (a path or query). Resolvers parse this; apl-core treats it + /// as opaque. + pub args: serde_yaml::Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum PdpDialect { + /// Bare Cedar policy evaluation (`apl-pdp-cedar-direct`). + Cedar, + /// Cedarling-mediated Cedar evaluation — same language but + /// adds signed policy stores, multi-issuer JWT validation, and + /// (with Lock Server) centralized policy management. Distinct + /// from `Cedar` so both can coexist in a single `PdpRouter`; + /// route YAML can target either with `cedar:(...)` or + /// `cedarling:(...)` keys. + Cedarling, + Opa, + AuthZen, + NeMo, + #[serde(untagged)] + Custom(String), +} + +impl PdpDialect { + /// Parse a YAML key prefix like `cedar`, `cedarling`, `opa`, + /// `authzen`, `nemo` into the matching `PdpDialect`. Unknown + /// dialects become `Custom`. + pub fn from_key(key: &str) -> Self { + match key { + "cedar" => Self::Cedar, + "cedarling" => Self::Cedarling, + "opa" => Self::Opa, + "authzen" => Self::AuthZen, + "nemo" => Self::NeMo, + other => Self::Custom(other.to_string()), + } + } +} + +// ===================================================================== +// Resolver traits +// ===================================================================== + +/// External policy-decision dispatch. Implemented by Cedar/Cedarling, OPA +/// HTTP clients, AuthZen clients, NeMo Guardrails — anything that can +/// answer "given this call, allow or deny?" against a request context. +/// +/// `apl-cpex` provides the bridge from CPEX plugins (e.g. `cedar-direct`) +/// to this trait so the host doesn't have to know about the plugin types. +#[async_trait] +pub trait PdpResolver: Send + Sync { + /// What dialect this resolver handles. The evaluator routes PDP steps + /// to the resolver whose `dialect()` matches `Step::Pdp.call.dialect`. + fn dialect(&self) -> PdpDialect; + + async fn evaluate( + &self, + call: &PdpCall, + bag: &crate::attributes::AttributeBag, + ) -> Result; +} + +/// Build a [`PdpResolver`] from a unified-config block. Implemented per +/// PDP backend (cedar-direct, cedarling, opa, …) and registered with +/// the apl-cpex visitor so unified-config YAML can declare PDPs +/// without the host pre-constructing them in code. +/// +/// Hosts register a factory by handing it to apl-cpex's +/// `AplOptions.pdp_factories`. When the visitor walks the unified +/// config and finds a `global.apl.pdp[].kind` matching the factory's +/// reported `kind()`, it calls `build` with the rest of that block. +/// +/// The error type is `Box` to keep this trait +/// in apl-core (which has no cpex deps). apl-cpex's visitor wraps +/// the boxed error into `VisitorError` → `PluginError::Config` at the +/// manager boundary. +pub trait PdpFactory: Send + Sync { + /// Identifies which `kind:` in a config block this factory handles. + /// Convention: kebab-case matching the published PDP product name + /// (`"cedar-direct"`, `"cedarling"`, `"opa"`, …). + fn kind(&self) -> &str; + + /// Build a resolver from the rest of the PDP config block (everything + /// under the same map level as `kind`). Implementations parse their + /// own config shape; missing or malformed fields surface here. + fn build( + &self, + config: &serde_yaml::Value, + ) -> Result, Box>; +} + +/// Where in the request lifecycle a plugin dispatch is happening. +/// Threads through `PluginInvocation` so the invoker can select the +/// right hook entry from a plugin that registered for both pre and +/// post phases (e.g. `cmf.tool_pre_invoke` AND `cmf.tool_post_invoke`). +/// +/// APL's four phases map to two dispatch phases: +/// * `args:` field stages → `Pre` +/// * `policy:` steps → `Pre` +/// * `result:` field stages → `Post` +/// * `post_policy:` steps → `Post` +/// +/// Plugins that need to discriminate `args` vs `policy` (same `Pre` +/// from the dispatcher's perspective) inspect `PluginContext::hook_name()` +/// inside their handler — the hook-routing layer doesn't slice phase +/// finer than Pre/Post. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DispatchPhase { + Pre, + Post, +} + +/// Context for one plugin invocation: tells the invoker the *intent* of +/// the call so it can dispatch to the right CPEX hook contract. +/// +/// `Step` is the policy / post_policy case — the invoker (apl-cpex side) +/// already holds a typed payload reference; APL doesn't need to pass one. +/// +/// `Field` is the pipe-chain case — APL is focused on a specific field +/// value mid-transform and the plugin may rewrite that value via +/// `PluginOutcome.modified_value`. +/// +/// Both variants carry a `DispatchPhase` so the invoker can resolve the +/// right hook entry against the cpex-core hook routing table when the +/// plugin registered for multiple hooks. +#[derive(Debug, Clone, Copy)] +pub enum PluginInvocation<'a> { + /// Called from a `policy:` or `post_policy:` step. The plugin operates + /// on whatever typed payload the invoker was bound to. + Step { phase: DispatchPhase }, + /// Called inside an `args:` / `result:` pipe chain on one field. + Field { + name: &'a str, + value: &'a serde_json::Value, + phase: DispatchPhase, + }, +} + +impl<'a> PluginInvocation<'a> { + /// Convenience: the dispatch phase carried by this invocation. + pub fn phase(&self) -> DispatchPhase { + match self { + PluginInvocation::Step { phase } => *phase, + PluginInvocation::Field { phase, .. } => *phase, + } + } +} + +/// Plugin invocation dispatch. apl-cpex wraps the CPEX `PluginManager` +/// behind this trait so the apl-core evaluator stays free of cpex-core +/// dependencies. +#[async_trait] +pub trait PluginInvoker: Send + Sync { + /// Invoke the named plugin against the current request context. The + /// `invocation` discriminates step vs pipe-chain call. + async fn invoke( + &self, + name: &str, + bag: &crate::attributes::AttributeBag, + invocation: PluginInvocation<'_>, + ) -> Result; +} + +/// Delegation dispatch — invokes a `TokenDelegateHook` plugin to mint +/// a downstream credential. apl-cpex implements this against +/// `cpex_core::PluginManager::invoke_entries::`. +/// +/// The invoker holds the request-scoped `Extensions` internally +/// (same pattern as `CmfPluginInvoker`), so the trait method doesn't +/// need to pass them — the invoker uses its own snapshot to construct +/// the `DelegationPayload` (inbound bearer token, subject, prior +/// delegation chain). +#[async_trait] +pub trait DelegationInvoker: Send + Sync { + /// Run one delegation step. Returns a `DelegationOutcome` carrying + /// the granted permissions / audience / expiry the IdP issued; the + /// evaluator writes those into the bag as `delegation.granted_*` + /// attributes so subsequent rules in the same step list can + /// inspect them via `require(delegation.granted_permissions + /// contains "X")` etc. + /// + /// `step.config_override` is layered on top of the plugin's + /// default config and threaded through the standard per-call + /// override pathway. + async fn delegate( + &self, + step: &DelegateStep, + ) -> Result; +} + +/// What a delegation invocation returned. +/// +/// On success, `decision` is `Allow` and the granted_* fields reflect +/// what the IdP actually issued (which may be narrower than what the +/// route asked for — `granted_permissions` is the source of truth for +/// what the downstream tool will accept). The evaluator surfaces these +/// into the bag under the `delegation.granted.*` sub-namespace plus a +/// `delegation.granted = true` flag. +/// +/// On `Deny`, granted_* fields are empty / `None` and the +/// `delegation.granted` flag is not set (absent → falsy). +#[derive(Debug, Clone)] +pub struct DelegationOutcome { + pub decision: Decision, + /// Permissions the IdP actually granted on the minted token. Empty + /// when the call failed or the plugin returned no token. + pub granted_permissions: Vec, + /// Audience the minted token is valid for. `None` when no token + /// was produced. + pub granted_audience: Option, + /// Token expiry (RFC 3339 string for bag-friendly representation). + /// `None` when no token was produced. + pub granted_expires_at: Option, +} + +impl DelegationOutcome { + /// Convenience for the "deny, nothing granted" case. + pub fn deny(decision: Decision) -> Self { + Self { + decision, + granted_permissions: Vec::new(), + granted_audience: None, + granted_expires_at: None, + } + } +} + +#[derive(Debug, Error)] +pub enum DelegationError { + #[error("no delegation invoker available for plugin `{0}`")] + NotFound(String), + + #[error("delegation dispatch failed: {0}")] + Dispatch(String), +} + +/// `DelegationInvoker` impl that returns `NotFound` for every call. +/// Useful as the default for evaluator callers that don't run any +/// `delegate(...)` steps — they need to pass *something* implementing +/// the trait, but the noop never actually gets invoked. Tests and +/// hosts that haven't wired a real delegation backend pass this. +pub struct NoopDelegationInvoker; + +#[async_trait] +impl DelegationInvoker for NoopDelegationInvoker { + async fn delegate( + &self, + step: &DelegateStep, + ) -> Result { + Err(DelegationError::NotFound(step.plugin_name.clone())) + } +} + +// ===================================================================== +// Resolver results +// ===================================================================== + +/// What a PDP returned. +#[derive(Debug, Clone, PartialEq)] +pub struct PdpDecision { + pub decision: Decision, + /// Optional diagnostic info: matched policy IDs, error codes, etc. + /// Surfaces in audit logs; not used for control flow. + pub diagnostics: Vec, +} + +/// What a plugin returned. +#[derive(Debug, Clone)] +pub struct PluginOutcome { + pub decision: Decision, + /// Plugins may apply taint labels as a side effect. Same shape as + /// config-emitted taints (`Step::Taint` / `Stage::Taint`) so the + /// downstream evaluator can append both into a single + /// `Vec` without converting. Each event may carry + /// multiple scopes — `CmfPluginInvoker` uses single-scope + /// (`Session`) for v0 but future invokers and plugins that emit + /// directly are free to span scopes. + pub taints: Vec, + /// Pipe-context return: when a plugin runs as a stage inside an + /// args/result chain, it may rewrite the field value (e.g., a PII + /// scrubber producing a redacted string). `None` means "leave value + /// unchanged"; always `None` for policy / post_policy invocations. + pub modified_value: Option, +} + +impl PluginOutcome { + /// Convenience for the common "allow, no taints, no value change" case. + pub fn allow() -> Self { + Self { decision: Decision::Allow, taints: vec![], modified_value: None } + } +} + +// ===================================================================== +// Errors +// ===================================================================== + +#[derive(Debug, Error)] +pub enum PdpError { + #[error("no PDP resolver registered for dialect {0:?}")] + NoResolver(PdpDialect), + + #[error("PDP dispatch failed: {0}")] + Dispatch(String), +} + +#[derive(Debug, Error)] +pub enum PluginError { + #[error("no plugin invoker available for `{0}`")] + NotFound(String), + + #[error("plugin dispatch failed: {0}")] + Dispatch(String), +} + +// ===================================================================== +// Convenience +// ===================================================================== + +impl Step { + /// Wrap a `Rule` as a `Step`. Saves typing in tests and parser code. + pub fn rule(r: Rule) -> Self { Step::Rule(r) } + + /// Returns true if this step is a plain rule (no async dispatch needed). + pub fn is_rule(&self) -> bool { matches!(self, Step::Rule(_)) } +} + +/// Bag keys the delegation step writes after a successful dispatch. +/// Centralized here so the evaluator (writer) and policy authors +/// (readers, via `require(delegation.granted.*)`) agree on the +/// canonical names — typos in either place silently break the +/// IdP-as-PDP pattern. +/// +/// # Namespace +/// +/// The `delegation.*` namespace at the top level carries INBOUND +/// chain attributes (`delegation.depth`, `delegation.origin`, +/// `delegation.chain`, ...) populated by identity resolver plugins +/// via `IdentityPayload.delegation` + apply-to-extensions, then +/// surfaced through apl-cmf's BagBuilder. See +/// `docs/specs/delegation-hooks-rust-spec.md` §6.3 for that mapping. +/// +/// The `delegation.granted.*` sub-namespace defined here is for +/// OUTBOUND results — what came back from a `delegate(...)` step +/// the framework just ran. Two writers (identity plugin for inbound, +/// `delegate(...)` for outbound), distinct sub-trees, no collision. +pub mod delegation_bag_keys { + /// `StringSet` — permissions actually granted by the IdP on the + /// minted token. May be narrower than `required_permissions`. + pub const GRANTED_PERMISSIONS: &str = "delegation.granted.permissions"; + /// `String` — audience the minted token is valid for. + pub const GRANTED_AUDIENCE: &str = "delegation.granted.audience"; + /// `String` — token expiry as RFC 3339. + pub const GRANTED_EXPIRES_AT: &str = "delegation.granted.expires_at"; + /// `Bool` — set to `true` after a successful `delegate(...)` + /// step. Lets policy branch on success without inspecting the + /// granted_permissions set: `require(delegation.granted)`. Absent + /// (i.e. evaluates to false) when no delegate step has run OR + /// when the most recent one denied. + pub const GRANTED: &str = "delegation.granted"; +} diff --git a/crates/apl-core/tests/yaml_end_to_end.rs b/crates/apl-core/tests/yaml_end_to_end.rs new file mode 100644 index 00000000..6227aaab --- /dev/null +++ b/crates/apl-core/tests/yaml_end_to_end.rs @@ -0,0 +1,266 @@ +// Location: ./crates/apl-core/tests/yaml_end_to_end.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end integration: YAML config → compiled IR → evaluated against a +// realistic AttributeBag and payload. This exercises the public crate API +// only (`compile_config` + `evaluate_route` + traits) and serves as the +// authoritative "if this passes, apl-core works as a unit" check. +// +// The fixture follows Example 1 from unified-config-proposal.md, adapted to +// the map-keyed `routes:` shape that the parser actually accepts (the spec's +// list-with-matchers form is a deferred shape). + +use std::sync::Arc; + +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, DelegationInvoker, FieldOutcome, + NoopDelegationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, + PluginInvocation, PluginInvoker, PluginOutcome, RoutePayload, +}; +use async_trait::async_trait; +use serde_json::json; + +// Test fixtures: every scenario passes the same no-op plugin invoker and +// no-op delegation invoker, so wrap them once in the `Arc` shape +// `evaluate_route` expects and let each call borrow. +fn pdp() -> Arc { + Arc::new(AllowPdp) +} +fn plugins() -> Arc { + Arc::new(NoPlugins) +} +fn delegations() -> Arc { + Arc::new(NoopDelegationInvoker) +} + +// ----- Fixtures: a baseline route used by every scenario below. ----- + +const HR_ROUTE_YAML: &str = r#" +routes: + get_employee: + args: + employee_id: "str" + policy: + - "require(authenticated)" + - "delegation.depth > 2: deny" + result: + ssn: "str | redact(!perm.view_ssn)" + salary: "int | redact(!role.hr)" + employee_id: "str | mask(4)" +"#; + +struct AllowPdp; +#[async_trait] +impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { PdpDialect::Cedar } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { decision: Decision::Allow, diagnostics: vec![] }) + } +} + +struct NoPlugins; +#[async_trait] +impl PluginInvoker for NoPlugins { + async fn invoke( + &self, + name: &str, + _bag: &AttributeBag, + _invocation: PluginInvocation<'_>, + ) -> Result { + Err(PluginError::NotFound(name.into())) + } +} + +// ----- Scenarios ----- + +#[tokio::test] +async fn alice_full_access_sees_unredacted_result_with_masked_id() { + // Alice: authenticated HR with view_ssn permission, depth=1. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("role.hr", true); + bag.set("perm.view_ssn", true); + bag.set("delegation.depth", 1_i64); + + let routes = compile_config(HR_ROUTE_YAML).expect("YAML compiles").routes; + let route = routes.get("get_employee").expect("route present"); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ + "ssn": "555-12-3456", + "salary": 95000, + "employee_id": "123-45-6789", + }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(r.args_modified == false, "args has only a `str` validator, no mutation"); + assert!(r.result_modified, "result has mask + redact stages"); + + let result = payload.result.as_ref().unwrap(); + // view_ssn=true → redact(!view_ssn) skipped → ssn intact. + assert_eq!(result["ssn"], json!("555-12-3456")); + // role.hr=true → redact(!role.hr) skipped → salary intact. + assert_eq!(result["salary"], json!(95000)); + // mask(4) always applies → keeps last 4 chars. + assert_eq!(result["employee_id"], json!("*******6789")); +} + +#[tokio::test] +async fn mallory_no_perm_no_role_gets_both_fields_redacted() { + // Mallory: authenticated but no role, no perm, shallow delegation. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 1_i64); + // role.hr and perm.view_ssn are absent → IsTrue=false → !IsTrue=true → redact fires. + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "555-44-3333" }), + json!({ + "ssn": "111-22-3333", + "salary": 80000, + "employee_id": "555-44-3333", + }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + + let result = payload.result.as_ref().unwrap(); + assert_eq!(result["ssn"], json!("[REDACTED]")); + assert_eq!(result["salary"], json!("[REDACTED]")); + assert_eq!(result["employee_id"], json!("*******3333")); +} + +#[tokio::test] +async fn deep_delegation_denies_at_policy() { + // Authenticated user but delegation.depth=3 > 2 → policy deny. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("role.hr", true); + bag.set("perm.view_ssn", true); + bag.set("delegation.depth", 3_i64); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ "ssn": "x", "salary": 1, "employee_id": "123-45-6789" }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!(rule_source.contains("policy"), "got source: {}", rule_source); + } + d => panic!("expected policy deny, got {:?}", d), + } + // Result phase never ran → no result mutation. + assert!(!r.result_modified); + assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("x")); + assert_eq!(payload.result.as_ref().unwrap()["employee_id"], json!("123-45-6789")); +} + +#[tokio::test] +async fn unauthenticated_user_is_denied_before_args_mutate_result() { + // No `authenticated` key → require(authenticated) fails → deny. + let mut bag = AttributeBag::new(); + bag.contains("authenticated"); // sanity: confirm we built an empty bag. + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": "123-45-6789" }), + json!({ "ssn": "999-99-9999", "salary": 50000, "employee_id": "123-45-6789" }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert!(matches!(r.decision, Decision::Deny { .. })); + assert!(!r.result_modified); +} + +#[tokio::test] +async fn args_validator_rejects_wrong_type() { + // args.employee_id is declared `str` — an integer value violates that + // and should produce a deny during the args phase, before policy runs. + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 1_i64); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::with_result( + json!({ "employee_id": 42 }), // ← wrong type + json!({ "ssn": "x", "salary": 1, "employee_id": "x" }), + ); + + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + match r.decision { + Decision::Deny { rule_source, .. } => { + assert!( + rule_source.contains("employee_id"), + "expected args field source, got {}", + rule_source, + ); + } + d => panic!("expected args-phase deny, got {:?}", d), + } + // Result phase didn't run. + assert!(!r.result_modified); +} + +#[tokio::test] +async fn inbound_only_evaluation_skips_result_phase() { + // Simulates the inbound path: payload has no result yet. Args + policy + // run; result phase is skipped; post_policy runs (none defined here). + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + bag.set("delegation.depth", 1_i64); + + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + + let mut payload = RoutePayload::new(json!({ "employee_id": "123-45-6789" })); + let r = evaluate_route(route, &mut bag, &mut payload, &pdp(), &plugins(), &delegations()).await; + assert_eq!(r.decision, Decision::Allow); + assert!(!r.result_modified); + assert!(payload.result.is_none()); + // Args field is untouched — `str` is validator-only, no transform. + assert_eq!(payload.args["employee_id"], json!("123-45-6789")); +} + +// ----- Smoke test: phase-existence reporting matches what's in the YAML. ----- + +#[test] +fn compiled_route_phase_set_reflects_yaml_blocks() { + use apl_core::Phase; + let routes = compile_config(HR_ROUTE_YAML).unwrap().routes; + let route = routes.get("get_employee").unwrap(); + let phases = route.declared_phases(); + assert!(phases.contains(Phase::Args)); + assert!(phases.contains(Phase::Policy)); + assert!(phases.contains(Phase::Result)); + assert!(!phases.contains(Phase::PostPolicy)); +} + +// Marker so the file isn't all `_` — sanity check that `FieldOutcome` is +// reachable as part of the public surface alongside the orchestrator's +// `RouteDecision`. Removing this when downstream consumers exist. +#[test] +fn public_surface_includes_field_outcome() { + let _: FieldOutcome = FieldOutcome::Pass; +} diff --git a/crates/apl-cpex/Cargo.toml b/crates/apl-cpex/Cargo.toml new file mode 100644 index 00000000..b0b50f4c --- /dev/null +++ b/crates/apl-cpex/Cargo.toml @@ -0,0 +1,43 @@ +# Location: ./crates/apl-cpex/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-cpex — the bridge between APL's evaluator (`apl-core`) and CPEX's +# runtime (`cpex-core`). Provides per-hook-type implementations of +# `apl-core::PluginInvoker` that translate APL plugin dispatch into typed +# `cpex-core::PluginManager::invoke_named::` calls. +# +# Design constraints inherited from `apl-core`: +# - `apl-core` has zero CPEX deps; cross-crate boundary lives here. +# - The PluginInvoker trait is string-typed; the typed boundary lives +# INSIDE each impl (one impl per HookTypeDef, e.g. CmfPluginInvoker +# for CMF, future DelegationPluginInvoker for delegation hooks). +# - Payload is built ONCE by the host and threaded through the invoker +# for the full request lifetime — never reconstructed from the bag. + +[package] +name = "apl-cpex" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +apl-cmf = { path = "../apl-cmf" } +cpex-core = { path = "../cpex-core" } +async-trait = { workspace = true } +chrono = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +# Stable hash for tier-3 (identity-derived) session id. `DefaultHasher` +# is explicitly documented as not-stable-across-Rust-versions; session +# keys persist across process restarts (SessionStore), so we need an +# algorithmically fixed hash. +sha2 = "0.10" + +[dev-dependencies] +serde = { workspace = true } diff --git a/crates/apl-cpex/src/cmf_invoker.rs b/crates/apl-cpex/src/cmf_invoker.rs new file mode 100644 index 00000000..6abb3cac --- /dev/null +++ b/crates/apl-cpex/src/cmf_invoker.rs @@ -0,0 +1,410 @@ +// Location: ./crates/apl-cpex/src/cmf_invoker.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CmfPluginInvoker` — `apl-core::PluginInvoker` impl bound to the CMF +// hook family. Drives dispatch off a pre-resolved [`RouteDispatchPlan`] +// (from [`DispatchCache`]) and forwards entries to +// `PluginManager::invoke_entries::(...)`, which runs the full +// executor pipeline (sequential / transform / audit / concurrent / +// fire-and-forget; on_error / timeouts / mode / write tokens all +// honored). Compile-time payload type safety is provided by the +// `CmfHook: HookTypeDef` bound on `invoke_entries`. +// +// # Request-scoped vs session-scoped state +// +// The invoker carries **request-scoped** state — payload + extensions +// — under interior mutability (`Arc>`) so mutations +// from one plugin call accumulate for the next call in the same +// request. **Session-scoped** state (labels that survive across requests +// in the same session) goes through the pluggable [`SessionStore`] +// trait: hydrated at `for_request` start, persisted via +// [`persist_session`] after route evaluation. Session ID is pulled from +// `extensions.agent.session_id`; absent → both ops are no-ops. +// +// # Per-call taint extraction +// +// Each plugin invocation diffs `result.modified_extensions.security.labels` +// against the labels visible to *that call*. New labels become +// `PluginOutcome.taints` as `TaintEvent { scopes: vec![Session] }` — +// CMF's monotonic label channel is session-semantic by design, so +// Session is the natural default. Multi-scope plugin emissions (or +// `Message` scope) require either a future second label channel in +// Extensions or explicit config-side `Step::Taint { scopes: [...] }` / +// `Stage::Taint`. +// +// # Lifetime model +// +// One invoker instance per request. Host pre-builds the +// `MessagePayload`, hydrates session-scoped state via `for_request` +// (which is async because it awaits `SessionStore::load_labels`), then +// drives `evaluate_route`. After evaluation, host calls +// [`current_payload`] for body re-serialization and +// [`persist_session`] to commit accumulated session state. +// +// Background tasks returned by `invoke_entries` are dropped for v0; +// when audit/fire-and-forget plugin support is wired into APL's +// lifecycle, we'll thread a `BackgroundTasks` aggregator through the +// invoker. + +use std::collections::HashSet; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::HookPhase; +use cpex_core::manager::PluginManager; + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::pipeline::{TaintEvent, TaintScope}; +use apl_core::step::{ + DispatchPhase, PluginError, PluginInvocation, PluginInvoker, PluginOutcome, +}; + +use crate::dispatch_plan::RouteDispatchPlan; +use crate::session_store::SessionStore; + +/// Bridges APL plugin dispatch to CMF-family CPEX hooks. +/// +/// Carries the request's `MessagePayload` and `Extensions` for its +/// entire lifetime so plugin mutations accumulate (one plugin's +/// `[REDACTED]` output is visible to the next plugin in the same +/// route; one plugin's added label seeds the next plugin's filter view). +pub struct CmfPluginInvoker { + manager: Arc, + /// Per-request extensions under interior mutability. Locked across + /// awaits — `tokio::sync::Mutex` is required because the executor's + /// `invoke_entries` is async. + extensions: Arc>, + /// Per-request payload under interior mutability. Same reasoning as + /// `extensions` — accumulated text rewrites have to be visible to + /// the next dispatch in the same request. + payload: Arc>, + /// Pre-resolved per-route plugin lineup. Built (or fetched from a + /// shared `DispatchCache`) at request start by the host. + plan: Arc, + /// Session ID resolved at request start by the 4-tier + /// [`session_resolver::resolve_session`] (token claim → header → + /// identity-derived → none). `None` for fully-anonymous traffic + /// (no claim, no header, no subject id) — hydration + persistence + /// become no-ops in that case. + session_id: Option, + /// Pluggable session-scoped state backend. `Arc` + /// rather than a generic so a single invoker type works for memory / + /// Redis / future-distributed stores without monomorphization churn. + session_store: Arc, + /// Labels present in `extensions.security.labels` immediately after + /// `SessionStore` hydration but before any plugins have run. Used + /// by `persist_session` to diff against final labels and append only + /// the additions to the session store. Empty when there was no + /// session_id (so no hydration happened). + initial_labels: HashSet, +} + +impl CmfPluginInvoker { + /// Construct an invoker bound to one request's payload + extensions + /// and the pre-resolved dispatch plan for the request's route. + /// Hydrates accumulated session-scoped labels into + /// `extensions.security.labels` before returning, so the first + /// plugin sees the full session-monotonic view. + pub async fn for_request( + manager: Arc, + mut extensions: Extensions, + payload: MessagePayload, + plan: Arc, + session_store: Arc, + ) -> Self { + // Resolve session id via the 4-tier resolver (token claim → + // header → identity-derived → none). Snapshotted before + // hydration so the lookup is independent of the COW write + // that hydration performs. + let session_id: Option = crate::session_resolver::resolve_session(&extensions) + .map(|(sid, _src)| sid); + + // Hydration: union the session's accumulated labels into the + // request's security labels. Skipped when there's no session_id + // OR no stored labels (avoid the COW clone for nothing). + if let Some(sid) = &session_id { + let stored = session_store.load_labels(sid).await; + if !stored.is_empty() { + extensions = hydrate_labels(extensions, &stored); + } + } + + let initial_labels = snapshot_labels(&extensions); + + Self { + manager, + extensions: Arc::new(Mutex::new(extensions)), + payload: Arc::new(Mutex::new(payload)), + plan, + session_id, + session_store, + initial_labels, + } + } + + /// Snapshot the current payload. Call after route evaluation to + /// extract the final (possibly-mutated) `MessagePayload` for body + /// re-serialization. + pub async fn current_payload(&self) -> MessagePayload { + self.payload.lock().await.clone() + } + + /// Snapshot the current extensions. Useful for hosts that need to + /// inspect the post-evaluation extension state (audit, telemetry). + pub async fn current_extensions(&self) -> Extensions { + self.extensions.lock().await.clone() + } + + /// Shared `Arc>` handle. Used by collaborators + /// (notably `DelegationPluginInvoker`) that need to mutate the + /// same request-scoped extensions this invoker sees — e.g. a + /// `delegate(...)` step minting a token needs to write + /// `raw_credentials.delegated_tokens.*` into the same Extensions + /// the next CMF plugin will read. + pub fn extensions_arc(&self) -> Arc> { + Arc::clone(&self.extensions) + } + + /// Shared `Arc` handle. Collaborators (e.g. + /// `DelegationPluginInvoker`) need this to look up their own + /// entries in the same per-route plan the CMF invoker uses. + pub fn plan_arc(&self) -> Arc { + Arc::clone(&self.plan) + } + + /// Drain APL-emitted session-scoped taints into the request's + /// `security.labels` so the existing label-monotonic flow + /// ([`persist_session`] below) picks them up. Filters by + /// `TaintScope::Session` — Message-scoped taints (and any future + /// scope) are deliberately ignored here; they have their own + /// destination (TBD: TS2 — a labels slot on `MessagePayload`). + /// + /// Host (`AplRouteHandler`) calls this once per request after + /// `evaluate_pre` / `evaluate_post` returns, with the + /// `RouteDecision.taints` slice. No-op when the slice has no + /// Session-scoped entries — common for routes that don't taint. + pub async fn apply_session_taints(&self, taints: &[apl_core::pipeline::TaintEvent]) { + use apl_core::pipeline::TaintScope; + use cpex_core::extensions::SecurityExtension; + + let session_labels: Vec<&str> = taints + .iter() + .filter(|t| t.scopes.contains(&TaintScope::Session)) + .map(|t| t.label.as_str()) + .collect(); + if session_labels.is_empty() { + return; + } + let mut current = self.extensions.lock().await; + // `Extensions.security` is `Option>`. + // Initialize the slot if absent; `Arc::make_mut` gives us a + // mutable reference to the underlying value, cloning when + // other Arc holders exist (e.g., a downstream snapshot reader). + let arc = current + .security + .get_or_insert_with(|| Arc::new(SecurityExtension::default())); + let sec = Arc::make_mut(arc); + for label in session_labels { + sec.add_label(label); + } + } + + /// Persist session-scoped state added during this request. Diffs + /// current `security.labels` against the post-hydration snapshot + /// and appends new labels to the session store. No-op when there + /// was no session ID. Host calls this exactly once after route + /// evaluation completes. + pub async fn persist_session(&self) { + let Some(sid) = &self.session_id else { return }; + let current = self.extensions.lock().await; + let Some(security) = current.security.as_ref() else { return }; + let new_labels: Vec = security + .labels + .iter() + .filter(|l| !self.initial_labels.contains(l.as_str())) + .cloned() + .collect(); + drop(current); // release the lock before the await + if !new_labels.is_empty() { + self.session_store.append_labels(sid, &new_labels).await; + } + } +} + +#[async_trait] +impl PluginInvoker for CmfPluginInvoker { + async fn invoke( + &self, + plugin_name: &str, + _bag: &AttributeBag, + invocation: PluginInvocation<'_>, + ) -> Result { + let resolved = self + .plan + .get(plugin_name) + .ok_or_else(|| PluginError::NotFound(plugin_name.to_string()))?; + + // Snapshot extensions to read entity_type — the dispatcher + // needs it for hook routing. Dropped immediately so we don't + // hold the lock across the per-entry payload clone. + let request_entity_type: Option = { + let ext = self.extensions.lock().await; + ext.meta.as_ref().and_then(|m| m.entity_type.clone()) + }; + + // Pick the entry whose registered hook matches the current + // dispatch context via cpex-core's hook metadata table. + // Replaces the prior naming heuristic. + let dispatch_phase = match invocation.phase() { + DispatchPhase::Pre => HookPhase::Pre, + DispatchPhase::Post => HookPhase::Post, + }; + let entry = resolved + .pick_entry(request_entity_type.as_deref(), dispatch_phase) + .ok_or_else(|| { + PluginError::Dispatch(format!( + "plugin '{plugin_name}' has no hook matching dispatch \ + context (entity_type={:?}, phase={:?}); declared hooks: {:?}", + request_entity_type, + dispatch_phase, + resolved.entries_by_hook.keys().collect::>(), + )) + })?; + + // Snapshot the current payload + extensions — `invoke_entries` + // consumes by-value, so we clone for the call and keep the + // canonical copies in shared state for the next dispatch. + let current_payload = self.payload.lock().await.clone(); + let current_extensions = self.extensions.lock().await.clone(); + + // Per-call taint diff baseline. New labels in `result` minus + // these become `PluginOutcome.taints`. + let before_labels = snapshot_labels(¤t_extensions); + + let (result, _bg) = self + .manager + .invoke_entries::( + std::slice::from_ref(entry), + current_payload, + current_extensions, + None, + ) + .await; + + // Map deny: violation reason → APL deny reason; plugin code → + // rule_source for audit attribution. + let decision = if result.is_denied() { + let (reason, rule_source) = match result.violation { + Some(v) => (Some(v.reason), v.code), + None => (None, "policy.forbidden".to_string()), + }; + Decision::Deny { reason, rule_source } + } else { + Decision::Allow + }; + + // Persist any plugin-side payload mutation back into the shared + // request payload. `PluginPayload` only exposes `as_any`, so we + // downcast-ref and clone. `MessagePayload: Clone` makes this + // cheap relative to the FFI/invoke cost. + let modified_value = if let Some(mp_boxed) = result.modified_payload.as_ref() { + match mp_boxed.as_any().downcast_ref::() { + Some(modified) => { + *self.payload.lock().await = modified.clone(); + match invocation { + PluginInvocation::Field { .. } => { + Some(serde_json::Value::String( + modified.message.get_text_content(), + )) + } + PluginInvocation::Step { .. } => None, + } + } + None => { + tracing::warn!( + plugin = %plugin_name, + "CmfPluginInvoker: modified_payload was not MessagePayload \ + (downcast failed) — dropping the mutation" + ); + None + } + } + } else { + None + }; + + // Promote modified extensions back into shared state + extract + // newly-added labels as taints. The executor returns + // `Option` for the modified view — `Some` only when + // a plugin actually changed extensions. The executor has + // already validated label monotonicity on the way out. + let taints = if let Some(modified_ext) = result.modified_extensions { + let after_labels = snapshot_labels(&modified_ext); + let new_labels: Vec = after_labels + .difference(&before_labels) + .cloned() + .collect(); + *self.extensions.lock().await = modified_ext; + new_labels + .into_iter() + .map(|label| TaintEvent { + label, + // v0: CMF's `security.labels` is session-semantic by + // design (monotonic accumulation). Plugins that need + // Message-scoped taints emit them via config-side + // `Step::Taint`/`Stage::Taint` for now. + scopes: vec![TaintScope::Session], + }) + .collect() + } else { + Vec::new() + }; + + Ok(PluginOutcome { + decision, + taints, + modified_value, + }) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Snapshot `extensions.security.labels` as an owned `HashSet`. +/// Empty when security is absent. +fn snapshot_labels(extensions: &Extensions) -> HashSet { + extensions + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default() +} + +/// Add `labels` to `extensions.security.labels` (monotonic union). +/// Creates a security extension if absent. Used at hydration time — +/// merges the SessionStore's accumulated labels into the request view +/// so the first plugin sees the full picture. +fn hydrate_labels(mut extensions: Extensions, labels: &[String]) -> Extensions { + // Clone the Arc'd security into an owned struct so we can mutate. + // Most slots stay refcount-shared; only security is materialized. + let mut security = extensions + .security + .as_ref() + .map(|s| (**s).clone()) + .unwrap_or_default(); + for l in labels { + security.add_label(l.clone()); + } + extensions.security = Some(Arc::new(security)); + extensions +} + diff --git a/crates/apl-cpex/src/delegation_invoker.rs b/crates/apl-cpex/src/delegation_invoker.rs new file mode 100644 index 00000000..c4447d47 --- /dev/null +++ b/crates/apl-cpex/src/delegation_invoker.rs @@ -0,0 +1,269 @@ +// Location: ./crates/apl-cpex/src/delegation_invoker.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `DelegationPluginInvoker` — `apl-core::DelegationInvoker` impl +// bound to the `TokenDelegateHook` family. Drives dispatch off a +// pre-resolved [`RouteDispatchPlan::delegation_entries`] and forwards +// to `PluginManager::invoke_entries::(...)`. +// +// # When this runs +// +// The apl-core evaluator calls +// `DelegationInvoker::delegate(&DelegateStep)` once per `Step::Delegate` +// it encounters in a `policy:` / `post_policy:` block. The invoker: +// +// 1. Looks up the resolved `token.delegate` entry for the step's +// plugin name in the dispatch plan. +// 2. Constructs a `cpex_core::delegation::DelegationPayload` from +// the inbound bearer token (from +// `Extensions.raw_credentials.inbound_tokens[User]`) plus the +// step's `config_override` (target / audience / permissions / +// attenuation — schema is plugin-defined; we map a few +// well-known keys onto the typed payload builders and stash +// everything else as metadata for plugin-specific consumption). +// 3. Calls `mgr.invoke_entries::(&[entry], ...)`. +// 4. Pulls the resulting `DelegationPayload` from the +// `PipelineResult`, applies it to the shared `Extensions` (via +// `apply_to_extensions`), and returns a `DelegationOutcome` with +// the granted_* fields extracted from the minted token. +// +// # Shared extensions +// +// This invoker shares the same `Arc>` as +// `CmfPluginInvoker` for the same request. That means when +// `delegate(...)` writes `raw_credentials.delegated_tokens.*`, the +// next CMF plugin in the chain (or downstream evaluator phases) sees +// it. Get the shared handle via `CmfPluginInvoker::extensions_arc()`. + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::SecondsFormat; +use tokio::sync::Mutex; + +use cpex_core::delegation::{ + payload::{AuthEnforcedBy, TargetType}, + DelegationPayload, TokenDelegateHook, +}; +use cpex_core::extensions::raw_credentials::TokenRole; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; + +use apl_core::evaluator::Decision; +use apl_core::step::{DelegateStep, DelegationError, DelegationInvoker, DelegationOutcome}; + +use crate::dispatch_plan::RouteDispatchPlan; + +/// Bridges APL `delegate(...)` step dispatch to CPEX +/// `TokenDelegateHook` plugins. +/// +/// Carries the request's shared `Extensions` so mutations from a +/// `delegate(...)` step (minted token, updated delegation chain) +/// land in the same `Extensions` the CMF invoker is reading. +pub struct DelegationPluginInvoker { + manager: Arc, + /// Same `Arc>` as the CMF invoker for this + /// request — sharing this handle is what makes minted tokens + /// visible to downstream CMF plugins. + extensions: Arc>, + /// Pre-resolved per-route delegation lineup. Built at request + /// start by the host (or fetched from a shared `DispatchCache`). + plan: Arc, +} + +impl DelegationPluginInvoker { + /// Construct an invoker bound to the request's shared extensions + /// and the route's pre-resolved dispatch plan. Take the + /// extensions Arc from `CmfPluginInvoker::extensions_arc()` so + /// the two invokers see the same mutable Extensions. + pub fn new( + manager: Arc, + extensions: Arc>, + plan: Arc, + ) -> Self { + Self { + manager, + extensions, + plan, + } + } +} + +#[async_trait] +impl DelegationInvoker for DelegationPluginInvoker { + async fn delegate( + &self, + step: &DelegateStep, + ) -> Result { + // 1. Resolve the plugin's token.delegate entry from the plan. + // Routes that don't reference this plugin in `policy:` / + // `post_policy:` at compile time won't have it in the plan + // — surface that as NotFound so the evaluator's on_error + // semantics kick in. + let entry = self + .plan + .delegation_entries + .get(&step.plugin_name) + .ok_or_else(|| DelegationError::NotFound(step.plugin_name.clone()))? + .clone(); + + // 2. Snapshot extensions to construct the payload + pass into + // invoke_entries. We keep the canonical copy under the + // Mutex; this snapshot is the per-call working copy. + let current_extensions = self.extensions.lock().await.clone(); + + // 3. Pull the inbound bearer token from raw_credentials. v0 + // looks for the User-role token; future iterations can + // surface multi-token selection (Client / Workload) via + // step config. + let bearer_token = current_extensions + .raw_credentials + .as_ref() + .and_then(|rc| rc.inbound_tokens.get(&TokenRole::User)) + .map(|tok| (*tok.token).clone()) + .unwrap_or_default(); + + // 4. Read step args. Step `config_override` is a yaml map per + // the IR — we extract a few well-known keys onto the typed + // DelegationPayload builders. Unknown keys still flow + // through to the plugin via the per-call config-override + // pathway at registration time (already applied when the + // plan was built — plugins consume them from their + // `cfg.config`). For Slice B we keep this mapping minimal: + // `target` is required (delegation needs to know who the + // downstream call is for); `audience`, `permissions`, + // `mode`, `auth_enforced_by` are recognized; everything + // else stays opaque. + let cfg = step + .config_override + .as_ref() + .and_then(|v| v.as_mapping()); + + let target_name: String = cfg + .and_then(|m| m.get(serde_yaml::Value::String("target".into()))) + .and_then(|v| v.as_str()) + .unwrap_or(&step.plugin_name) + .to_string(); + + let mut payload = DelegationPayload::new(bearer_token, target_name); + + if let Some(audience) = cfg + .and_then(|m| m.get(serde_yaml::Value::String("audience".into()))) + .and_then(|v| v.as_str()) + { + payload = payload.with_target_audience(audience); + } + if let Some(perms) = cfg + .and_then(|m| m.get(serde_yaml::Value::String("permissions".into()))) + .and_then(|v| v.as_sequence()) + { + let list: Vec = perms + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + if !list.is_empty() { + payload = payload.with_required_permissions(list); + } + } + if let Some(t_kind) = cfg + .and_then(|m| m.get(serde_yaml::Value::String("target_type".into()))) + .and_then(|v| v.as_str()) + { + payload = payload.with_target_type(target_type_from_str(t_kind)); + } + if let Some(enforcer) = cfg + .and_then(|m| m.get(serde_yaml::Value::String("auth_enforced_by".into()))) + .and_then(|v| v.as_str()) + { + payload = payload.with_auth_enforced_by(auth_enforced_by_from_str(enforcer)); + } + + // 5. Dispatch. The plan's pre-resolved entry already has any + // per-route config override merged into the plugin's + // instance config; what we're passing on this call is the + // typed payload (target / audience / permissions / etc.). + let (result, _bg) = self + .manager + .invoke_entries::( + std::slice::from_ref(&entry), + payload, + current_extensions, + None, + ) + .await; + + // 6. Translate the result. + if !result.continue_processing { + // Plugin denied (IdP refusal, validation failure, etc.). + let decision = match result.violation { + Some(v) => Decision::Deny { + reason: Some(v.reason), + rule_source: v.code, + }, + None => Decision::Deny { + reason: Some(format!( + "delegate `{}` denied without violation detail", + step.plugin_name + )), + rule_source: step.source.clone(), + }, + }; + return Ok(DelegationOutcome::deny(decision)); + } + + // 7. Pull the resolved DelegationPayload and apply to shared + // extensions so downstream code sees the minted token / + // updated chain. + let resolved = DelegationPayload::from_pipeline_result(&result).ok_or_else(|| { + DelegationError::Dispatch(format!( + "plugin `{}` returned allow but no DelegationPayload", + step.plugin_name, + )) + })?; + + { + let mut ext_lock = self.extensions.lock().await; + let merged = resolved.clone().apply_to_extensions(ext_lock.clone()); + *ext_lock = merged; + } + + // 8. Extract granted_* for the evaluator to surface into the bag. + let (granted_permissions, granted_audience, granted_expires_at) = + match resolved.delegated_token { + Some(tok) => ( + tok.scopes, + Some(tok.audience), + Some(tok.expires_at.to_rfc3339_opts(SecondsFormat::Secs, true)), + ), + None => (Vec::new(), None, None), + }; + + Ok(DelegationOutcome { + decision: Decision::Allow, + granted_permissions, + granted_audience, + granted_expires_at, + }) + } +} + +fn target_type_from_str(s: &str) -> TargetType { + match s.to_ascii_lowercase().as_str() { + "tool" => TargetType::Tool, + "agent" => TargetType::Agent, + "resource" => TargetType::Resource, + "service" => TargetType::Service, + other => TargetType::Custom(other.to_string()), + } +} + +fn auth_enforced_by_from_str(s: &str) -> AuthEnforcedBy { + match s.to_ascii_lowercase().as_str() { + "caller" => AuthEnforcedBy::Caller, + "target" => AuthEnforcedBy::Target, + // Unknown values default to Caller — matches DelegationPayload::new's default. + _ => AuthEnforcedBy::Caller, + } +} diff --git a/crates/apl-cpex/src/dispatch_plan.rs b/crates/apl-cpex/src/dispatch_plan.rs new file mode 100644 index 00000000..3fbafdb9 --- /dev/null +++ b/crates/apl-cpex/src/dispatch_plan.rs @@ -0,0 +1,461 @@ +// Location: ./crates/apl-cpex/src/dispatch_plan.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `RouteDispatchPlan` + `DispatchCache` — pre-resolved per-route plugin +// lineup that lets APL bypass cpex-core's hook-name + condition routing +// while still going through the executor's full 5-phase pipeline. +// +// # Why pre-resolve? +// +// cpex-core's `invoke_named(hook_name, ...)` resolves the lineup on +// every call: hook lookup → route/condition filter → group by mode → +// dispatch. APL routes are already authoritative lineups (the YAML's +// `routes..policy: [plugin(x), plugin(y)]` IS the plan). Re-resolving +// per call wastes work and lets cpex-core's parallel routing model +// (entity-based conditions) override APL's intent. +// +// Building once per `(route_key, snapshot_generation)` and caching turns +// dispatch into: cache lookup → pick handler by invocation context → +// call `manager.invoke_entries::(&[entry], ...)`. +// +// # Override materialization +// +// When APL declares a route-level `plugins.:` block that narrows +// `capabilities` or changes `on_error`, the plan creates a derived +// `PluginRef` wrapping the same plugin `Arc` with a merged +// `TrustedConfig`. Per `feedback_override_isolation.md`: each derived +// PluginRef gets a fresh `AtomicBool` circuit breaker — failures in the +// override-context plugin don't disable the base, and vice versa. +// +// # Hook-context classification (v0) +// +// A plugin may register handlers for multiple hooks (e.g. both +// `cmf.tool_pre_invoke` for policy steps and `cmf.field_redact` for +// args/result pipelines). The plan picks one handler per invocation +// context (Step vs Field) by a naming heuristic — hook names containing +// `field`, `redact`, `scan`, or `validate` are treated as field +// handlers. When the heuristic stops being sufficient, the plugin +// declaration will gain an explicit `{step: ..., field: ...}` mapping +// form alongside the flat hook list. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; + +use cpex_core::delegation::HOOK_TOKEN_DELEGATE; +use cpex_core::hooks::{lookup_hook_metadata, HookPhase}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::OnError; +use cpex_core::registry::HookEntry; + +use apl_core::pipeline::Stage; +use apl_core::plugin_decl::{EffectivePlugin, PluginRegistry}; +use apl_core::rules::{CompiledRoute, Effect}; + +/// Per-plugin pre-resolved entries for one route. Stores ALL hook +/// entries the plugin registered (keyed by hook name) so the +/// dispatcher can pick the right one for the current context via the +/// cpex-core hook routing table (`hooks::metadata::lookup`). +/// +/// Replaces the prior `step_entry` / `field_entry` slot model, which +/// used a brittle naming heuristic to classify hooks and silently +/// collapsed plugins with multiple step-context hooks (e.g. both +/// `tool_pre_invoke` and `tool_post_invoke`) to a single entry. +#[derive(Clone)] +pub struct RoutePluginEntry { + pub plugin_name: String, + /// All hook entries the plugin registered, keyed by hook name. + /// Per-call overrides (route-level config / caps / on_error) are + /// already applied via `build_override_entries` before being + /// stored here. + pub entries_by_hook: HashMap, +} + +impl RoutePluginEntry { + /// Pick the entry whose registered hook matches the current + /// dispatch context. Walks `entries_by_hook`, consults the + /// cpex-core hook metadata table for each, returns the first + /// matching entry. + /// + /// `requested_entity_type` comes from the request's + /// `MetaExtension.entity_type` (or `None` if the dispatcher + /// doesn't have one — in which case any hook's entity_type + /// matches). `requested_phase` comes from the APL invocation + /// context — `Pre` for `args:` / `policy:`, `Post` for + /// `result:` / `post_policy:`, `Unphased` for unphased + /// dispatchers (rare in APL). + /// + /// Returns `None` when the plugin has no hook matching the + /// context — caller surfaces this as `PluginError::Dispatch` + /// with the requested context in the message. + pub fn pick_entry( + &self, + requested_entity_type: Option<&str>, + requested_phase: HookPhase, + ) -> Option<&HookEntry> { + self.entries_by_hook + .iter() + .find(|(hook_name, _)| { + lookup_hook_metadata(hook_name) + .matches(requested_entity_type, requested_phase) + }) + .map(|(_, entry)| entry) + } +} + +/// A route's resolved plugin lineup. One per `(route_key, generation)` +/// in the cache. +/// +/// `plugins` holds entries for CMF-family dispatch (policy steps, +/// pipe-chain stages). `delegation_entries` holds entries for the +/// `token.delegate` hook used by `Step::Delegate` — kept separate +/// because the hook family is different and the dispatch is +/// per-call rather than per-route-chain. +#[derive(Clone, Default)] +pub struct RouteDispatchPlan { + pub plugins: HashMap, + /// Plugin name → resolved `token.delegate` hook entry for routes + /// that declared `delegate(...)` steps. Empty when the route has + /// no delegation. Built at plan time to avoid per-request + /// `find_plugin_entries` lookups in the hot path. + pub delegation_entries: HashMap, +} + +impl RouteDispatchPlan { + /// Build a plan for the given route. Walks all steps + pipeline + /// stages, collects the unique set of plugin names, resolves each + /// against cpex-core, and applies any APL route-level overrides. + /// + /// Plugins referenced by APL but absent from cpex-core's registry + /// (or absent from the APL `plugins:` block) are logged at `warn` + /// and excluded — dispatch then fails with `PluginError::NotFound` + /// when those plugins are invoked, which is the right behavior for + /// surfacing config drift. + pub async fn build( + route: &CompiledRoute, + registry: &PluginRegistry, + manager: &PluginManager, + ) -> Self { + let mut plan = Self::default(); + for name in collect_plugin_names(route) { + let eff = match EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) { + Some(e) => e, + None => { + tracing::warn!( + plugin = %name, + route = %route.route_key, + "APL route references plugin not in `plugins:` block — skipping", + ); + continue; + } + }; + + // Pull the three overrideable values off the effective view. + // `EffectivePlugin` borrows from the registry / route overrides, + // so the captures here are slice / Option<&Value> refs. + let override_block = route.plugin_overrides.get(&name); + let config_override = override_block.and_then(|o| o.config.as_ref()); + let caps_override: Option> = + if matches!(eff.capabilities, apl_core::plugin_decl::CapsView::Override(_)) { + Some(eff.capabilities.as_slice().iter().cloned().collect()) + } else { + None + }; + let on_error_override = override_block + .and_then(|o| o.on_error.as_deref()) + .and_then(parse_on_error); + + // Hand the override decision to cpex-core. When no overrides + // are declared, this returns the base entries unchanged + // (no allocation, no factory call). When only caps/on_error + // differ, it wraps the shared base plugin in a fresh + // `PluginRef` with merged trusted config. When config + // differs, it invokes the factory + initializes a brand-new + // instance with its own circuit breaker. + let entries = manager + .build_override_entries( + &name, + config_override, + caps_override.as_ref(), + on_error_override, + ) + .await; + if entries.is_empty() { + tracing::warn!( + plugin = %name, + route = %route.route_key, + "APL plugin not resolvable (not registered, factory missing, \ + or override construction failed) — skipping", + ); + continue; + } + + // Store every (hook_name, HookEntry) pair the plugin + // registered. Dispatch-time entry selection (pick_entry) + // consults cpex-core's hook routing table per hook name. + // Replaces the prior naming heuristic. + let mut entries_by_hook: HashMap = HashMap::new(); + for (hook_name, entry) in entries { + entries_by_hook.insert(hook_name, entry); + } + + plan.plugins.insert( + name.clone(), + RoutePluginEntry { + plugin_name: name, + entries_by_hook, + }, + ); + } + + // Resolve token.delegate entries for any plugins the route + // calls via `Step::Delegate`. These don't go through the + // step/field classification — they're a separate hook family. + // We still apply per-call config overrides via the existing + // `build_override_entries` pathway, threading the step's + // `config_override` as the only override surface (Slice B + // doesn't expose per-step caps or on_error overrides on + // delegation entries — the on_error lives in the IR step + // itself and is honored by the evaluator). + for name in collect_delegate_plugin_names(route) { + let entries = manager + .build_override_entries(&name, None, None, None) + .await; + // Pick the first token.delegate entry. Per delegation-hooks + // spec, plugins typically register one handler under the + // single `token.delegate` hook name; multiple handlers + // would be unusual. + let delegate_entry = entries + .into_iter() + .find(|(hook_name, _)| hook_name == HOOK_TOKEN_DELEGATE); + if let Some((_, entry)) = delegate_entry { + plan.delegation_entries.insert(name, entry); + } else { + tracing::warn!( + plugin = %name, + route = %route.route_key, + "APL route references delegate plugin not registered under \ + token.delegate hook — `delegate(...)` step will fail at dispatch", + ); + } + } + + plan + } + + /// Look up the resolved entries for a plugin by name. None when the + /// plugin wasn't referenced by the route (or was skipped during + /// build due to config drift). + pub fn get(&self, plugin_name: &str) -> Option<&RoutePluginEntry> { + self.plugins.get(plugin_name) + } + + /// Resolve a single plugin's entries straight off cpex-core, with + /// no APL route-level overrides. Convenience for tests and for hosts + /// that wire the invoker without a `CompiledRoute` in scope (e.g. + /// adapters that invoke a single plugin imperatively). Returns + /// `None` if cpex-core has no entries for the plugin. + pub fn resolve_plugin( + manager: &PluginManager, + plugin_name: &str, + ) -> Option { + let base_entries = manager.find_plugin_entries(plugin_name); + if base_entries.is_empty() { + return None; + } + let mut entries_by_hook: HashMap = HashMap::new(); + for (hook_name, entry) in base_entries { + entries_by_hook.insert(hook_name, entry); + } + Some(RoutePluginEntry { + plugin_name: plugin_name.to_string(), + entries_by_hook, + }) + } +} + +fn parse_on_error(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "fail" => Some(OnError::Fail), + "ignore" => Some(OnError::Ignore), + "disable" => Some(OnError::Disable), + _ => None, + } +} + +/// Recursively walk every effect node in an `Effect` tree, invoking +/// `visit` on each. Used by `collect_*_names` below to find Plugin / +/// Delegate references that may be nested inside `Effect::When`, +/// `Effect::Sequential`, `Effect::Parallel`, or `Effect::Pdp` reaction +/// lists. Pre-E4 these were flat — Step::Plugin lived directly under +/// policy: — so a simple iter() was enough; after E4 the IR is tree- +/// shaped and the same scan needs recursion. +fn walk_effects(effects: &[Effect], visit: &mut F) { + for e in effects { + visit(e); + match e { + Effect::When { body, .. } => walk_effects(body, visit), + Effect::Sequential(inner) | Effect::Parallel(inner) => walk_effects(inner, visit), + Effect::Pdp { on_allow, on_deny, .. } => { + walk_effects(on_allow, visit); + walk_effects(on_deny, visit); + } + _ => {} + } + } +} + +/// Walk a `CompiledRoute` and return the unique delegate-plugin names +/// referenced by any `Effect::Delegate` anywhere in `policy` / +/// `post_policy` (including effects nested inside When / Sequential / +/// Parallel / Pdp reactions). Insertion-ordered for build determinism. +/// Separate from [`collect_plugin_names`] because delegate plugins +/// resolve under a different hook family (`token.delegate`) and the +/// dispatch plan keeps them in a separate map. +pub(crate) fn collect_delegate_plugin_names(route: &CompiledRoute) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut visit = |e: &Effect| { + if let Effect::Delegate(ds) = e { + if seen.insert(ds.plugin_name.clone()) { + out.push(ds.plugin_name.clone()); + } + } + }; + walk_effects(&route.policy, &mut visit); + walk_effects(&route.post_policy, &mut visit); + out +} + +/// Walk a `CompiledRoute` and return the unique plugin names referenced +/// by any `Effect::Plugin` anywhere in `policy` / `post_policy` (including +/// nested) or `Stage::Plugin` (in `args` / `result` pipelines). +/// Insertion-ordered for build determinism. +pub(crate) fn collect_plugin_names(route: &CompiledRoute) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut visit = |e: &Effect| { + if let Effect::Plugin { name } = e { + if seen.insert(name.clone()) { + out.push(name.clone()); + } + } + }; + walk_effects(&route.policy, &mut visit); + walk_effects(&route.post_policy, &mut visit); + for fr in route.args.iter().chain(route.result.iter()) { + for stage in &fr.pipeline.stages { + if let Stage::Plugin { name } = stage { + if seen.insert(name.clone()) { + out.push(name.clone()); + } + } + } + } + out +} + +/// Compute the union of capabilities declared by every plugin a +/// `CompiledRoute` can dispatch to (with per-route overrides applied). +/// +/// This is what the synthetic `AplRouteHandler`'s `PluginConfig.capabilities` +/// must be set to: cpex-core's executor filters the `Extensions` view +/// before invoking every plugin (including the synthetic one), so if +/// the handler has fewer capabilities than its inner plugins need, +/// downstream views get doubly-filtered and label/delegation mutations +/// fail monotonicity checks on the way back out. +/// +/// Plugins missing from the registry are silently skipped — the +/// dispatch plan will log a `warn!` and surface a `NotFound` at +/// invocation time, so config drift surfaces in the right place +/// rather than as a confusing capability gap. +pub(crate) fn route_capability_union( + route: &CompiledRoute, + registry: &PluginRegistry, +) -> std::collections::HashSet { + let mut caps: std::collections::HashSet = std::collections::HashSet::new(); + // Plugin steps (`plugin(name)` in policy / `plugin: name` in + // args / result pipelines). + for name in collect_plugin_names(route) { + if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) { + for cap in eff.capabilities.as_slice() { + caps.insert(cap.clone()); + } + } + } + // Delegate steps (`delegate(name, ...)`). Without this, a + // delegator plugin that declares `capabilities: + // [read_inbound_credentials, write_delegated_tokens]` in YAML + // gets those stripped at the AplRouteHandler boundary — the + // synthetic handler doesn't union its caps in, so the executor + // filters out the inbound bearer before DelegationPluginInvoker + // dispatches, and the delegator handler sees an empty token. + // Hosts WANT to express per-plugin caps in YAML rather than + // widening the AplRouteHandler's baseline (which would leak + // those creds to every other step in the route). + for name in collect_delegate_plugin_names(route) { + if let Some(eff) = EffectivePlugin::resolve(&name, registry, &route.plugin_overrides) { + for cap in eff.capabilities.as_slice() { + caps.insert(cap.clone()); + } + } + } + caps +} + +/// Host-owned dispatch cache. Construct once, share via `Arc` +/// across all `CmfPluginInvoker::for_request` calls so plans built for +/// one request can be reused by the next. +/// +/// Cache key is the APL `route_key`. Entries pair with the cpex-core +/// snapshot generation observed at build time; a mismatch on lookup +/// triggers eviction and rebuild. v0 keys on `route_key` only — +/// entity-aware caching (entity_type/entity_name from `MetaExtension`) +/// is a follow-up when per-tenant lineup variation lands. +#[derive(Default)] +pub struct DispatchCache { + inner: RwLock)>>, +} + +impl DispatchCache { + pub fn new() -> Self { + Self::default() + } + + /// Get-or-build a plan for the route. Read-locked fast path returns + /// the cached plan when the generation matches; otherwise drop the + /// read lock, rebuild, and write-lock-insert. The brief window + /// between read-miss and write-insert may let two concurrent + /// builders race — both produce identical plans and the second + /// insert just overwrites the first. Cheap relative to the cost of + /// the build itself, and avoids holding a write lock across the + /// build call. + /// + /// Async because `RouteDispatchPlan::build` may invoke + /// `PluginManager::build_override_entries`, which calls plugin + /// factories and `initialize()` for routes that declare `config:` + /// overrides. Routes with no overrides take a synchronous path + /// inside the manager (no `.await` does any real work), so the + /// async cost is zero for the common case. + pub async fn get_or_build( + &self, + route: &CompiledRoute, + registry: &PluginRegistry, + manager: &PluginManager, + ) -> Arc { + let current_gen = manager.config_generation(); + { + let r = self.inner.read().unwrap_or_else(|p| p.into_inner()); + if let Some((stored_gen, plan)) = r.get(&route.route_key) { + if *stored_gen == current_gen { + return Arc::clone(plan); + } + } + } + let plan = Arc::new(RouteDispatchPlan::build(route, registry, manager).await); + let mut w = self.inner.write().unwrap_or_else(|p| p.into_inner()); + w.insert(route.route_key.clone(), (current_gen, Arc::clone(&plan))); + plan + } +} diff --git a/crates/apl-cpex/src/lib.rs b/crates/apl-cpex/src/lib.rs new file mode 100644 index 00000000..b5f6aaef --- /dev/null +++ b/crates/apl-cpex/src/lib.rs @@ -0,0 +1,52 @@ +// Location: ./crates/apl-cpex/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-cpex — bridge between APL evaluator (`apl-core`) and CPEX runtime +// (`cpex-core`). +// +// `apl-core::PluginInvoker` is string-typed by design (so `apl-core` +// stays free of CPEX deps). The actual typed boundary lives in this +// crate: one `PluginInvoker` implementation per `HookTypeDef`. The +// payload type is locked at the impl level — e.g. [`CmfPluginInvoker`] +// can only dispatch to CMF hooks because every internal call goes +// through `invoke_named::`, and the compiler enforces that +// the payload is `MessagePayload`. +// +// # v0 simplification — single-view-per-Message +// +// CMF spec §4.2 distinguishes two messaging patterns: +// - LLM wire format — bundled multi-part Messages (thinking + text + +// tool_call(s)) — many MessageViews per Message. +// - Framework/protocol format (MCP, A2A, LangGraph) — single +// ContentPart per Message — one view per Message. +// +// v0 only handles request-side flows (outbound LLM call from the user, +// outbound MCP tools/call from the agent). Both are single-part, so the +// route → MessageView matching collapses to "one route fires per +// Message." When response-side handling lands, this assumption breaks +// and apl-core's route-matching layer needs to switch from +// routes-as-map to routes-as-list with a `match:` block filtering on +// MessageView attributes. See the APL implementation memory's +// "list-with-matchers" deferred item. + +pub mod cmf_invoker; +pub mod delegation_invoker; +pub mod dispatch_plan; +pub mod parallel_safety; +pub mod pdp_router; +pub mod register; +pub mod route_handler; +pub mod session_resolver; +pub mod session_store; +pub mod visitor; + +pub use cmf_invoker::CmfPluginInvoker; +pub use delegation_invoker::DelegationPluginInvoker; +pub use dispatch_plan::{DispatchCache, RouteDispatchPlan, RoutePluginEntry}; +pub use pdp_router::PdpRouter; +pub use register::{register_apl, AplOptions}; +pub use route_handler::{AplRouteHandler, Phase}; +pub use session_store::{MemorySessionStore, SessionStore}; +pub use visitor::AplConfigVisitor; diff --git a/crates/apl-cpex/src/parallel_safety.rs b/crates/apl-cpex/src/parallel_safety.rs new file mode 100644 index 00000000..2550927b --- /dev/null +++ b/crates/apl-cpex/src/parallel_safety.rs @@ -0,0 +1,343 @@ +// Location: ./crates/apl-cpex/src/parallel_safety.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Route-compile-time plugin-mode validation for APL `parallel:` blocks. +// +// `apl-core::Effect::validate_parallel_purity` already rejects FieldOp / +// Delegate at the IR level — those are statically detectable without +// any plugin knowledge. Plugin calls (`Effect::Plugin { name }`) need +// a second pass because their concurrency-safety depends on each +// plugin's registered `PluginMode` — information that lives in the +// PluginManager, not the IR. +// +// Lives in apl-cpex because: +// * apl-core can't see plugin modes (plugin-agnostic by design) +// * The PluginManager is constructed in the host integration, not in +// apl-core's compiler +// * The visitor that turns YAML routes into `CompiledRoute`s is the +// natural place to run all post-IR-level validations together +// +// # Mode rules +// +// Allowed inside `parallel:`: +// - `Audit` — read-only by declaration +// - `Concurrent` — explicitly designed for parallel execution +// - `FireAndForget` — side-effects only, no return value to merge +// - `Disabled` — skipped at runtime anyway +// +// Rejected inside `parallel:`: +// - `Sequential` — `can_modify() == true`, would silently lose its mutation +// - `Transform` — same as Sequential for our purposes +// +// The asymmetry exists because parallel branches each get a *cloned* +// bag and payload; any mutation a branch makes lives only inside its +// clone. Plugins authored under Sequential / Transform semantics +// reasonably assume their writes persist. Detecting the misuse at +// route-compile means the operator sees a clear error instead of a +// confusing "but my plugin ran and the bag didn't change" runtime +// surprise. + +use apl_core::rules::{CompiledRoute, Effect}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::PluginMode; + +/// Read-only "what mode is plugin X registered with" lookup, used by +/// the validator. A trait (rather than a `&PluginManager`) so: +/// +/// * Tests can pass a small HashMap-backed mock without constructing +/// a real `PluginManager` (which requires plugin registration and +/// a bunch of cpex-core internal types). +/// * Future consumers that store plugin modes in a different shape +/// (e.g. a separate config catalogue) plug in without forcing them +/// to back the lookup with a full PluginManager. +pub trait PluginModeLookup { + /// Returns the mode for `name`, or `None` if no plugin by that + /// name is registered. + fn mode_for(&self, name: &str) -> Option; +} + +impl PluginModeLookup for PluginManager { + fn mode_for(&self, name: &str) -> Option { + self.get_plugin(name).map(|p| p.mode()) + } +} + +/// Walk a compiled route looking for `Effect::Plugin` calls nested +/// inside any `Effect::Parallel` block, and check that each named +/// plugin's registered mode is safe for parallel execution. +/// +/// Returns `Ok(())` if all plugins inside parallel blocks have safe +/// modes (or the route has no parallel blocks). On failure, returns a +/// `;`-separated list of every violation found — running a single pass +/// over the route surfaces all problems at once instead of stopping +/// at the first. +pub fn validate_parallel_plugin_modes( + route: &CompiledRoute, + registry: &L, +) -> Result<(), String> { + let mut errors: Vec = Vec::new(); + for (phase_name, effects) in [ + ("policy", route.policy.as_slice()), + ("post_policy", route.post_policy.as_slice()), + ] { + for (idx, effect) in effects.iter().enumerate() { + walk_effect( + effect, + &format!("routes.{}.{}[{}]", route.route_key, phase_name, idx), + false, + registry, + &mut errors, + ); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +/// Recursive traversal. `under_parallel` is true once we've descended +/// into a `Parallel` node; from then on every `Plugin` we hit gets +/// checked against the mode allowlist. Nested `Parallel`/`Sequential` +/// both keep the flag true (a sequential block inside a parallel one +/// is still ultimately running in the parallel branch's cloned state). +fn walk_effect( + effect: &Effect, + location: &str, + under_parallel: bool, + registry: &L, + errors: &mut Vec, +) { + match effect { + Effect::Plugin { name } if under_parallel => { + check_plugin_mode(name, location, registry, errors); + } + Effect::Parallel(inner) => { + for e in inner { + walk_effect(e, location, true, registry, errors); + } + } + Effect::Sequential(inner) => { + for e in inner { + walk_effect(e, location, under_parallel, registry, errors); + } + } + Effect::When { body, .. } => { + // A `when:` body inherits the parallel context of its + // enclosing scope. Plugin calls inside `when:` under a + // `parallel:` are still subject to the mode check. + for e in body { + walk_effect(e, location, under_parallel, registry, errors); + } + } + Effect::Pdp { on_allow, on_deny, .. } => { + for e in on_allow.iter().chain(on_deny.iter()) { + walk_effect(e, location, under_parallel, registry, errors); + } + } + // Other variants (Allow/Deny/Plugin-not-in-parallel/Delegate/ + // Taint/FieldOp) don't carry nested effects today. Note that + // `Delegate` / `FieldOp` inside Parallel was already rejected + // by `apl-core::Effect::validate_parallel_purity` at parse + // time — no need to re-check here. + _ => {} + } +} + +fn check_plugin_mode( + name: &str, + location: &str, + registry: &L, + errors: &mut Vec, +) { + let mode = match registry.mode_for(name) { + Some(m) => m, + None => { + errors.push(format!( + "{}: `parallel:` references unknown plugin `{}`", + location, name + )); + return; + } + }; + if !is_safe_in_parallel(mode) { + errors.push(format!( + "{}: plugin `{}` has mode `{}` which can modify state; parallel \ + branches discard mutations, so this would silently lose its effect. \ + Use `sequential:` for ordered mutations or change the plugin's mode.", + location, name, mode, + )); + } +} + +/// Allowlist check. Centralised so the rule is documented in one +/// place and easy to find if `PluginMode` gains a new variant. +fn is_safe_in_parallel(mode: PluginMode) -> bool { + matches!( + mode, + PluginMode::Audit + | PluginMode::Concurrent + | PluginMode::FireAndForget + | PluginMode::Disabled + ) +} + +// ===================================================================== +// Tests +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use apl_core::rules::Expression; + use std::collections::HashMap; + + /// Test mock — a plain `HashMap`. Implements the + /// lookup trait without needing the real cpex-core registry's + /// plugin / hook registration machinery. + struct MockLookup(HashMap); + + impl MockLookup { + fn new() -> Self { + Self(HashMap::new()) + } + fn with(mut self, name: &str, mode: PluginMode) -> Self { + self.0.insert(name.to_string(), mode); + self + } + } + + impl PluginModeLookup for MockLookup { + fn mode_for(&self, name: &str) -> Option { + self.0.get(name).copied() + } + } + + fn route_with_policy(effects: Vec) -> CompiledRoute { + let mut r = CompiledRoute::new("test_route"); + r.policy = effects; + r + } + + fn rule(effects: Vec) -> Effect { + Effect::When { + condition: Expression::Always, + body: effects, + source: "test".into(), + } + } + + fn parallel_plugin(name: &str) -> Effect { + Effect::Parallel(vec![Effect::Plugin { name: name.into() }]) + } + + // --- Allowed modes --- + + #[test] + fn audit_plugin_in_parallel_is_accepted() { + let reg = MockLookup::new().with("audit_logger", PluginMode::Audit); + let route = route_with_policy(vec![rule(vec![parallel_plugin("audit_logger")])]); + assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); + } + + #[test] + fn concurrent_plugin_in_parallel_is_accepted() { + let reg = MockLookup::new().with("pii_scanner", PluginMode::Concurrent); + let route = route_with_policy(vec![rule(vec![parallel_plugin("pii_scanner")])]); + assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); + } + + #[test] + fn fire_and_forget_in_parallel_is_accepted() { + let reg = MockLookup::new().with("metrics", PluginMode::FireAndForget); + let route = route_with_policy(vec![rule(vec![parallel_plugin("metrics")])]); + assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); + } + + // --- Rejected modes --- + + #[test] + fn sequential_plugin_in_parallel_is_rejected() { + let reg = MockLookup::new().with("mutator", PluginMode::Sequential); + let route = route_with_policy(vec![rule(vec![parallel_plugin("mutator")])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("mutator"), "names plugin: {}", err); + assert!(err.contains("sequential"), "names mode: {}", err); + assert!(err.contains("`sequential:`"), "suggests fix: {}", err); + } + + #[test] + fn transform_plugin_in_parallel_is_rejected() { + let reg = MockLookup::new().with("redactor", PluginMode::Transform); + let route = route_with_policy(vec![rule(vec![parallel_plugin("redactor")])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("transform")); + } + + #[test] + fn unknown_plugin_in_parallel_is_rejected() { + let reg = MockLookup::new(); + let route = route_with_policy(vec![rule(vec![parallel_plugin("ghost")])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("unknown plugin")); + assert!(err.contains("ghost")); + } + + // --- Scoping: only mismatches INSIDE a parallel block are caught --- + + #[test] + fn sequential_plugin_outside_parallel_is_allowed() { + // The same Sequential-mode plugin is fine at the top level — + // only its appearance INSIDE a parallel block is the problem. + let reg = MockLookup::new().with("mutator", PluginMode::Sequential); + let route = route_with_policy(vec![rule(vec![Effect::Plugin { + name: "mutator".into(), + }])]); + assert!(validate_parallel_plugin_modes(&route, ®).is_ok()); + } + + #[test] + fn nested_sequential_inside_parallel_still_validates_plugins() { + // `parallel: [sequential: [plugin(seq_mode)]]` — the sequential + // is just a grouping construct; the plugin still runs inside + // the parallel branch's cloned state. + let reg = MockLookup::new().with("mutator", PluginMode::Sequential); + let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![ + Effect::Sequential(vec![Effect::Plugin { + name: "mutator".into(), + }]), + ])])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("mutator")); + } + + // --- Diagnostics: every violation, both phases --- + + #[test] + fn multiple_violations_all_reported() { + // Surface every violation in one pass so the operator can fix + // them all at once instead of one error per build cycle. + let reg = MockLookup::new() + .with("a", PluginMode::Sequential) + .with("b", PluginMode::Transform); + let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![ + Effect::Plugin { name: "a".into() }, + Effect::Plugin { name: "b".into() }, + ])])]); + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("`a`"), "names a: {}", err); + assert!(err.contains("`b`"), "names b: {}", err); + } + + #[test] + fn post_policy_phase_is_validated_too() { + let reg = MockLookup::new().with("mutator", PluginMode::Sequential); + let mut route = CompiledRoute::new("test_route"); + route.post_policy = vec![rule(vec![parallel_plugin("mutator")])]; + let err = validate_parallel_plugin_modes(&route, ®).unwrap_err(); + assert!(err.contains("post_policy")); + } +} diff --git a/crates/apl-cpex/src/pdp_router.rs b/crates/apl-cpex/src/pdp_router.rs new file mode 100644 index 00000000..bbfa12fa --- /dev/null +++ b/crates/apl-cpex/src/pdp_router.rs @@ -0,0 +1,207 @@ +// Location: ./crates/apl-cpex/src/pdp_router.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `PdpRouter` — composite `PdpResolver` that dispatches each call to the +// resolver matching the requested `PdpDialect`. Lets a single host (or a +// single `AplRouteHandler`) carry resolvers for Cedar **and** OPA **and** +// NeMo at the same time without having to pick one at construction. +// +// Routing is by dialect equality. The first registered resolver for a +// given dialect wins on duplicate registration — registering Cedar twice +// keeps the original and logs a warning. Unknown-dialect calls return +// `PdpError::NoResolver(dialect)`. +// +// `PdpRouter` is itself a `PdpResolver`, so it slots straight into +// `AplRouteHandler::with_pdp`. Its own `dialect()` method returns +// `PdpDialect::Custom("router")` — a sentinel the evaluator doesn't +// branch on; only inner resolvers' dialects matter. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; + +use apl_core::attributes::AttributeBag; +use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; + +/// Dispatches PDP calls to the right resolver based on +/// `Step::Pdp.call.dialect`. Construct with `new()`, add resolvers via +/// `register`, then hand the router to a route handler. +/// +/// Cloning is cheap (refcount bumps on each resolver `Arc`) — the +/// `AplConfigVisitor` snapshots its accumulated router into an `Arc` +/// for every installed route handler so a config reload that mutates +/// the visitor state doesn't tear in-flight handlers. +#[derive(Clone)] +pub struct PdpRouter { + resolvers: HashMap>, +} + +impl PdpRouter { + pub fn new() -> Self { + Self { + resolvers: HashMap::new(), + } + } + + /// Register a resolver for its declared dialect. If a resolver is + /// already registered for that dialect the new one is dropped and a + /// warning is logged — explicit replacement should go through + /// `replace` instead so the intent is visible at call sites. + pub fn register(&mut self, resolver: Arc) -> &mut Self { + let dialect = resolver.dialect(); + if self.resolvers.contains_key(&dialect) { + tracing::warn!( + dialect = ?dialect, + "PdpRouter: resolver for dialect already registered — keeping existing", + ); + return self; + } + self.resolvers.insert(dialect, resolver); + self + } + + /// Replace any existing resolver for the new resolver's dialect. + /// Use this when the host genuinely wants to swap in a different + /// implementation (testing, A/B rollout). + pub fn replace(&mut self, resolver: Arc) -> &mut Self { + let dialect = resolver.dialect(); + self.resolvers.insert(dialect, resolver); + self + } + + /// Number of registered resolvers. Useful for tests. + pub fn len(&self) -> usize { + self.resolvers.len() + } + + pub fn is_empty(&self) -> bool { + self.resolvers.is_empty() + } +} + +impl Default for PdpRouter { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl PdpResolver for PdpRouter { + fn dialect(&self) -> PdpDialect { + // Sentinel — evaluator routes per `Step::Pdp.call.dialect`, not + // the resolver's own declared dialect. The router never claims to + // be one of the real dialects so a stray equality check can't + // accidentally pick it. + PdpDialect::Custom("router".to_string()) + } + + async fn evaluate( + &self, + call: &PdpCall, + bag: &AttributeBag, + ) -> Result { + let resolver = self + .resolvers + .get(&call.dialect) + .ok_or_else(|| PdpError::NoResolver(call.dialect.clone()))?; + resolver.evaluate(call, bag).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use apl_core::evaluator::Decision; + + struct FakePdp { + dialect: PdpDialect, + decision: Decision, + } + + #[async_trait] + impl PdpResolver for FakePdp { + fn dialect(&self) -> PdpDialect { + self.dialect.clone() + } + + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { + decision: self.decision.clone(), + diagnostics: Vec::new(), + }) + } + } + + #[tokio::test] + async fn routes_by_dialect() { + let mut router = PdpRouter::new(); + router.register(Arc::new(FakePdp { + dialect: PdpDialect::Cedar, + decision: Decision::Allow, + })); + router.register(Arc::new(FakePdp { + dialect: PdpDialect::Opa, + decision: Decision::Deny { + reason: Some("opa says no".into()), + rule_source: "opa".into(), + }, + })); + + let bag = AttributeBag::default(); + let cedar_call = PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::Value::Null, + }; + let opa_call = PdpCall { + dialect: PdpDialect::Opa, + args: serde_yaml::Value::Null, + }; + + let cedar_res = router.evaluate(&cedar_call, &bag).await.unwrap(); + assert!(matches!(cedar_res.decision, Decision::Allow)); + + let opa_res = router.evaluate(&opa_call, &bag).await.unwrap(); + assert!(matches!(opa_res.decision, Decision::Deny { .. })); + } + + #[tokio::test] + async fn missing_dialect_returns_no_resolver() { + let router = PdpRouter::new(); + let bag = AttributeBag::default(); + let call = PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::Value::Null, + }; + let err = router.evaluate(&call, &bag).await.unwrap_err(); + assert!(matches!(err, PdpError::NoResolver(_))); + } + + #[tokio::test] + async fn duplicate_register_keeps_first() { + let mut router = PdpRouter::new(); + router.register(Arc::new(FakePdp { + dialect: PdpDialect::Cedar, + decision: Decision::Allow, + })); + router.register(Arc::new(FakePdp { + dialect: PdpDialect::Cedar, + decision: Decision::Deny { + reason: Some("shouldn't fire".into()), + rule_source: "test".into(), + }, + })); + let call = PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::Value::Null, + }; + let res = router.evaluate(&call, &AttributeBag::default()).await.unwrap(); + assert!(matches!(res.decision, Decision::Allow)); + } +} diff --git a/crates/apl-cpex/src/register.rs b/crates/apl-cpex/src/register.rs new file mode 100644 index 00000000..ab6becf2 --- /dev/null +++ b/crates/apl-cpex/src/register.rs @@ -0,0 +1,185 @@ +// Location: ./crates/apl-cpex/src/register.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `register_apl` — sugar function that bundles "construct +// `AplConfigVisitor` + register it with the manager" into one call. +// +// Hosts that just want APL governance with sensible defaults call this +// instead of building the visitor by hand. The lower-level +// `PluginManager::register_visitor` API stays available for custom +// orchestrators (future Rego, Cedar-direct, hand-rolled audit visitors) +// that don't fit the APL setup. +// +// # Two ways to supply PDPs +// +// PDP resolvers can reach the visitor's internal `PdpRouter` via two +// channels, and `AplOptions` exposes both: +// +// * `pdps` — code-supplied resolvers. The host built them +// in Rust (e.g. a hand-rolled audit resolver, +// a test fake) and hands them in directly. +// * `pdp_factories` — factories the visitor consults when it sees a +// `global.apl.pdp[]` entry in the unified +// config. Each factory advertises a `kind()` +// string that matches the YAML block's `kind:` +// field. +// +// Both channels feed the same `PdpRouter` inside the visitor, so a +// host can mix the two freely — code-supplied Cedar for tests plus a +// config-declared OPA in prod, say. + +use std::collections::HashSet; +use std::sync::Arc; + +use cpex_core::manager::PluginManager; +use cpex_core::visitor::ConfigVisitor; + +use apl_core::step::{PdpFactory, PdpResolver}; + +use crate::dispatch_plan::DispatchCache; +use crate::session_store::SessionStore; +use crate::visitor::AplConfigVisitor; + + +/// Configuration for [`register_apl`]. All runtime collaborators APL +/// needs to do its work are funneled through here so the call site +/// reads as a single block instead of a multi-step builder. +pub struct AplOptions { + /// Shared dispatch-plan cache. One `Arc` per host + /// instance — clones are cheap (refcount bump) and the cache is + /// internally synchronized. + pub dispatch_cache: Arc, + + /// Pluggable session-scoped state. `MemorySessionStore` is the + /// default in-process backend; production hosts swap in Redis / + /// DynamoDB-backed impls. + pub session_store: Arc, + + /// Zero or more code-supplied PDP resolvers. Each is registered + /// into the visitor's internal `PdpRouter`, so `pdp(...)` steps + /// dispatch by dialect across this list **and** any resolvers the + /// visitor builds from `global.apl.pdp[]` config entries. An empty + /// list combined with empty `pdp_factories` means no PDP is wired + /// — routes that call `pdp(...)` surface `PdpError::NoResolver` at + /// evaluation time, which is the correct behavior for "you forgot + /// to configure your policy decision point." + pub pdps: Vec>, + + /// PDP factories the visitor consults when it encounters a + /// `global.apl.pdp[]` entry. Each factory advertises a `kind()` + /// string that matches the YAML block's `kind:` field — e.g. + /// `cedar-direct`, `cedarling`, `opa`. An empty list disables + /// config-driven PDP wiring; hosts can still supply resolvers via + /// `pdps`. + pub pdp_factories: Vec>, + + /// Override the visitor's baseline capabilities for installed + /// `AplRouteHandler`s. `None` uses the visitor's default + /// (read-only across the common attribute namespaces); `Some(set)` + /// replaces it entirely. The per-route plugin capability union is + /// added on top regardless — this only controls the baseline. + /// + /// Set to `Some(HashSet::new())` for strict deployments where + /// only plugin-declared caps should be granted. + pub base_capabilities: Option>, +} + +impl AplOptions { + /// Minimal options — in-process dispatch cache + memory session + /// store, no PDP, default baseline capabilities. Useful for tests + /// and single-process demos. + pub fn in_process() -> Self { + Self { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(crate::session_store::MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: None, + } + } +} + +/// Build an [`AplConfigVisitor`] from the supplied options and register +/// it on the manager. Returns the `Arc` so the caller +/// can stash it for later inspection (or call `register_pdp` on it +/// after the fact for late-bound resolvers) — but in the typical case +/// the return value is dropped and the visitor lives inside the +/// manager's visitor list. +/// +/// After this call, the next `mgr.load_config_yaml(yaml)` invocation +/// will walk the visitor: cpex-core's [`visit_plugins`][vp] populates +/// the APL plugin registry from `&[PluginConfig]`; `visit_global` +/// processes any `global.apl.pdp[]` entries by dispatching to the +/// registered `pdp_factories`; the hierarchy walk stacks `global.apl` +/// / `defaults..apl` / `policies..apl` / route-level +/// `apl:` into compiled routes; one `AplRouteHandler` is installed +/// per route per phase via [`PluginManager::annotate_route`][ar]. +/// +/// [vp]: cpex_core::visitor::ConfigVisitor::visit_plugins +/// [ar]: cpex_core::manager::PluginManager::annotate_route +/// +/// # Example +/// +/// ```ignore +/// use std::sync::Arc; +/// use cpex_core::manager::PluginManager; +/// use apl_cpex::{register_apl, AplOptions}; +/// use apl_pdp_cedar_direct::CedarDirectPdpFactory; +/// +/// let mgr = Arc::new(PluginManager::default()); +/// mgr.register_factory("scope-gate", Box::new(ScopeGateFactory)); +/// +/// apl_cpex::register_apl(&mgr, AplOptions { +/// dispatch_cache: dispatch_cache.clone(), +/// session_store: session_store.clone(), +/// pdps: vec![], // none code-supplied +/// pdp_factories: vec![Arc::new(CedarDirectPdpFactory::new())], +/// base_capabilities: None, +/// }); +/// +/// mgr.load_config_yaml(&yaml_string)?; +/// mgr.initialize().await?; +/// ``` +pub fn register_apl( + mgr: &Arc, + opts: AplOptions, +) -> Arc { + let AplOptions { + dispatch_cache, + session_store, + pdps, + pdp_factories, + base_capabilities, + } = opts; + + // Build the visitor and apply consuming builders first (these take + // `self` by value), then mutating registrations (`&mut self` for + // factories), and finally wrap in `Arc` so we can hand the shared + // handle to the manager. Code-supplied PDPs go through + // `register_pdp(&self, ...)` which uses interior mutability, so + // they're registered after the `Arc` wrap. + let mut visitor = AplConfigVisitor::new( + dispatch_cache, + session_store, + Arc::downgrade(mgr), + ); + + if let Some(caps) = base_capabilities { + visitor = visitor.with_base_capabilities(caps); + } + + for factory in pdp_factories { + visitor.register_pdp_factory(factory); + } + + let arc = Arc::new(visitor); + + for pdp in pdps { + arc.register_pdp(pdp); + } + + mgr.register_visitor(Arc::clone(&arc) as Arc); + arc +} diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs new file mode 100644 index 00000000..d1d1b838 --- /dev/null +++ b/crates/apl-cpex/src/route_handler.rs @@ -0,0 +1,569 @@ +// Location: ./crates/apl-cpex/src/route_handler.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor, Fred Araujo +// +// `AplRouteHandler` — synthetic plugin that drives APL evaluation when +// cpex-core's `filter_entries_by_route` matches an annotated route. Each +// instance is bound to ONE phase (Pre or Post) so the unified-config +// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` hooks can carry +// distinct handler logic without an in-handler hook-name discriminator. +// +// # Why a phase-bound handler +// +// The CPEX manager's annotation table is keyed on +// `(entity_type, entity_name, scope, hook_name)`. The visitor registers +// one handler per route per phase; the manager picks the right one based +// on the dispatching hook name. Inside `invoke`, no hook-name plumbing is +// needed — the handler already knows which phase it's running. +// +// # Lifetime / weak manager handle +// +// The handler holds `Weak` because the manager owns the +// snapshot that owns the annotation that owns the handler — a strong +// reference would create a cycle. Each `invoke` upgrades to `Arc` for +// the duration of the call. If the upgrade fails (manager has been +// dropped) the call returns a configuration error. + +use std::sync::{Arc, Weak}; + +use async_trait::async_trait; +use serde_json::Value; + +use cpex_core::cmf::MessagePayload; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::executor::ErasedResultFields; +use cpex_core::extensions::Extensions; +use cpex_core::hooks::PluginPayload; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use apl_cmf::{extract_args, extract_result, BagBuilder}; +use apl_core::evaluator::Decision; +use apl_core::plugin_decl::PluginRegistry; +use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload}; +use apl_core::rules::CompiledRoute; +use apl_core::step::PdpResolver; + +use crate::cmf_invoker::CmfPluginInvoker; +use crate::delegation_invoker::DelegationPluginInvoker; +use crate::dispatch_plan::DispatchCache; +use crate::pdp_router::PdpRouter; +use crate::session_store::SessionStore; + +/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post +/// covers `result` + `post_policy`. Set once at construction and never +/// changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + Pre, + Post, +} + +/// Synthetic plugin that drives APL evaluation for one route + one phase. +/// +/// Implements `Plugin` (so cpex-core treats it like any other plugin — +/// mode/capabilities/on_error come from the `PluginConfig` the visitor +/// supplied at `annotate_route` time) and `AnyHookHandler` (so the +/// executor dispatches into it through the normal type-erased path). +pub struct AplRouteHandler { + config: PluginConfig, + route: Arc, + phase: Phase, + plugin_registry: Arc, + dispatch_cache: Arc, + session_store: Arc, + /// Weak handle to the manager so we can resolve plugin entries + + /// dispatch into them by-name. `Weak` avoids the + /// manager↔snapshot↔annotation↔handler cycle. + manager: Weak, + /// PDP resolver. APL routes that don't use `pdp(...)` steps never + /// touch this. Default is an empty [`PdpRouter`] — any `pdp(...)` + /// step against an unregistered dialect returns + /// `PdpError::NoResolver`. Hosts that need Cedar, OPA, NeMo, etc. + /// install resolvers via [`Self::with_pdp`] or + /// [`Self::with_pdp_router`]. + pdp: Arc, +} + +impl AplRouteHandler { + /// Build a handler. Visitor calls this twice per route — once for + /// each phase — and passes the resulting `Arc` to `annotate_route`. + pub fn new( + config: PluginConfig, + route: Arc, + phase: Phase, + plugin_registry: Arc, + dispatch_cache: Arc, + session_store: Arc, + manager: Weak, + ) -> Self { + Self { + config, + route, + phase, + plugin_registry, + dispatch_cache, + session_store, + manager, + pdp: Arc::new(PdpRouter::new()), + } + } + + /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs + /// to support multiple dialects (Cedar + OPA + NeMo) on the same + /// route — the router dispatches each `pdp(...)` step by dialect. + /// Pass a single resolver when only one dialect is in use; APL + /// steps for any other dialect will then return + /// `PdpError::NoResolver` at evaluation time. + pub fn with_pdp(mut self, pdp: Arc) -> Self { + self.pdp = pdp; + self + } + + /// Sugar for the common "register many resolvers" path. Builds a + /// [`PdpRouter`], registers each resolver into it, then installs the + /// router. Equivalent to constructing a `PdpRouter` by hand and + /// passing it to [`Self::with_pdp`]. + pub fn with_pdp_router( + mut self, + resolvers: impl IntoIterator>, + ) -> Self { + let mut router = PdpRouter::new(); + for r in resolvers { + router.register(r); + } + self.pdp = Arc::new(router); + self + } +} + +#[async_trait] +impl Plugin for AplRouteHandler { + fn config(&self) -> &PluginConfig { + &self.config + } +} + +#[async_trait] +impl AnyHookHandler for AplRouteHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> Result, Box> { + // Downcast to the CMF payload — this handler only registers for + // cmf.* hook names, so the executor should always hand us a + // MessagePayload. A mismatch indicates a framework wiring bug. + let msg_payload = payload + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "AplRouteHandler '{}': payload was not MessagePayload", + self.route.route_key + ), + }) + })?; + + let manager = self.manager.upgrade().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "AplRouteHandler '{}': PluginManager dropped before invoke", + self.route.route_key + ), + }) + })?; + + // Build (or reuse) the dispatch plan for this route. Cache keyed + // by `(route_key, manager.config_generation())` — if the manager + // has reloaded since the last invoke, the next lookup rebuilds. + let plan = self + .dispatch_cache + .get_or_build(&self.route, &self.plugin_registry, &manager) + .await; + + // CmfPluginInvoker carries the request-scoped payload + extensions + // under interior mutability so successive plugin calls accumulate + // mutations. Hydration + persistence are no-ops when there's no + // session id (the common case for the first request in a session). + // Wrapped in Arc so it can be erased to `Arc` + // for the apl-core entry points (which take `&Arc` + // so `dispatch_parallel` can clone an owned, 'static reference into + // each spawned branch). Inherent-method calls on `CmfPluginInvoker` + // (e.g. `extensions_arc`, `persist_session`) deref through the Arc. + let invoker = Arc::new( + CmfPluginInvoker::for_request( + Arc::clone(&manager), + extensions.clone(), + msg_payload.clone(), + plan, + Arc::clone(&self.session_store), + ) + .await, + ); + + // Build the attribute bag. APL predicates read flat keys; the + // BagBuilder bridges typed CPEX extensions into that namespace. + // `route.key` lets default/policy-bundle predicates branch on + // which route they're attached to. + let post_extensions = invoker.current_extensions().await; + let mut bag = BagBuilder::new() + .with_extensions(&post_extensions) + .with_route_key(&self.route.route_key) + .build(); + + // Build `RoutePayload.args` from the message. Per-content shape: + // * ToolCall → arguments map (JSON Object) + // * PromptRequest → arguments map (JSON Object) + // * Text-only → JSON String of concatenated text content + // + // Field pipelines operate on `args.` paths. Result starts + // as Null on Pre (no upstream response yet); the Post phase + // would extract from a ToolResult / PromptResult — deferred + // until result-side handling lands. + let args_value = extract_args_from_message(&msg_payload.message); + let mut route_payload = match self.phase { + Phase::Pre => RoutePayload::new(args_value), + Phase::Post => { + // Pull the upstream result out of the message so APL + // `result.` predicates and the `result:` + // pipeline have something to operate on. Falls back to + // `Value::Null` when the message has no ToolResult / + // PromptResult / Resource content (e.g. for hooks that + // fire on entities without a structured result). + let result_value = extract_result_from_message(&msg_payload.message); + RoutePayload::with_result(args_value, result_value) + } + }; + + // Flatten the call args into the bag under `args.`. APL's + // own args pipelines read from `route_payload.args` directly, + // but PDP steps and predicates that reference `${args.X}` / + // `args.X` resolve through the bag. Mirroring the args here + // makes both consumers see the same vocabulary the + // `MessageView` exposes. (Bag-mutation via redact during the + // args pipeline isn't reflected back into the bag; that's fine + // — args predicates today read from `route_payload.args`, and + // the cedar substitution snapshots the pre-args view, which is + // what an author writing `cedar:(resource.id: ${args.X})` would + // expect.) + extract_args(&route_payload.args, &mut bag); + // Post phase: also project the upstream result into the bag + // under `result.`. This is what enables predicates like + // `redact(result.ssn) when !perm.view_ssn` and `require(...)` + // gates that branch on the result. Pre phases skip this — the + // result is `None` by construction. + if matches!(self.phase, Phase::Post) { + if let Some(result_value) = route_payload.result.as_ref() { + extract_result(result_value, &mut bag); + } + } + + // Slice B: real delegation invoker, sharing the CMF invoker's + // extensions Mutex so a `delegate(...)` step's writes to + // raw_credentials / delegation are visible to downstream CMF + // plugins and to the post phase. Routes that don't declare + // any `Step::Delegate` won't have entries in the plan's + // `delegation_entries` map; if such a route accidentally hits + // `delegate(...)`, the invoker returns `NotFound` and the + // evaluator translates it via the step's `on_error`. + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&manager), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + // Unsized coercion: `Arc` → `Arc`. The + // erased forms get borrowed into `evaluate_pre`/`evaluate_post`; + // `dispatch_parallel` can then `Arc::clone` an owned 'static + // reference into each branch closure. + let invoker_dyn: Arc = invoker.clone(); + let delegations_dyn: Arc = delegations.clone(); + + let decision = match self.phase { + Phase::Pre => { + evaluate_pre( + &self.route, + &mut bag, + &mut route_payload, + &self.pdp, + &invoker_dyn, + &delegations_dyn, + ) + .await + } + Phase::Post => { + evaluate_post( + &self.route, + &mut bag, + &mut route_payload, + &self.pdp, + &invoker_dyn, + &delegations_dyn, + ) + .await + } + }; + + // Drain Session-scoped taints (from `taint(label, session)` / + // pipeline `Stage::Taint`) into `extensions.security.labels` + // so the existing label-diff flow inside `persist_session` + // picks them up. Message-scoped taints are filtered out by + // `apply_session_taints` — they need their own destination + // (see TS2). No-op when no taints emitted. + invoker.apply_session_taints(&decision.taints).await; + + // Commit any session-scoped labels accumulated during this + // request. No-op when there was no session id. + invoker.persist_session().await; + + // Surface the final mutated payload + extensions back into the + // PipelineResult the executor returns to the host. The host's + // body re-serialization picks up edits made by APL pipelines + // (e.g. a redact stage that rewrote args.text). + let final_payload = invoker.current_payload().await; + let final_extensions = invoker.current_extensions().await; + + // Detect whether the args pipeline mutated the payload by + // re-extracting from the pre-eval message (msg_payload is + // still borrowed) and comparing against the post-eval + // route_payload.args. Re-extraction allocates but mirrors the + // surrounding pattern and avoids holding a pre-eval clone. + let pre_args = extract_args_from_message(&msg_payload.message); + // For Post phase, also detect result mutations from `result:` + // pipelines. Pre routes don't carry a result so this is None. + let pre_result = match self.phase { + Phase::Pre => None, + Phase::Post => Some(extract_result_from_message(&msg_payload.message)), + }; + let modified_payload: Option> = + if route_payload.args != pre_args { + // An args pipeline (Pre) rewrote a field. Fold the new + // args back into a fresh MessagePayload so downstream + // readers (the host's body re-serializer) see the + // change. + let mut updated = final_payload.clone(); + write_args_back_to_message(&mut updated.message, &route_payload.args); + Some(Box::new(updated) as Box) + } else if matches!(self.phase, Phase::Post) + && pre_result + .as_ref() + .zip(route_payload.result.as_ref()) + .map(|(prev, current)| prev != current) + .unwrap_or(false) + { + // A `result:` pipeline rewrote a field in the upstream + // response. Fold the new result back into the message + // so the host's response body re-serializer can write + // it out before forwarding downstream. + let mut updated = final_payload.clone(); + if let Some(result_value) = route_payload.result.as_ref() { + write_result_back_to_message(&mut updated.message, result_value); + } + Some(Box::new(updated) as Box) + } else if msg_payload.message.get_text_content() + != final_payload.message.get_text_content() + { + // A `policy:` plugin mutated the message directly via + // `modify_payload` (not through a field pipeline). Pass + // the invoker's view through unchanged. + Some(Box::new(final_payload) as Box) + } else { + None + }; + + let modified_extensions = if extensions_changed(extensions, &final_extensions) { + Some(final_extensions.cow_copy()) + } else { + None + }; + + let (continue_processing, violation) = match decision.decision { + Decision::Allow => (true, None), + Decision::Deny { reason, rule_source } => { + let code = if rule_source.is_empty() { + "policy.deny".to_string() + } else { + rule_source + }; + let reason = reason.unwrap_or_else(|| "access denied".to_string()); + (false, Some(PluginViolation::new(code, reason))) + } + }; + + Ok(Box::new(ErasedResultFields { + continue_processing, + modified_payload, + modified_extensions, + violation, + })) + } + + fn hook_type_name(&self) -> &'static str { + // CmfHook::NAME — kept as a literal here to avoid pulling in the + // HookTypeDef trait just for the constant. + "cmf" + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Rewrite the first text part of `msg` with `new_text`. If there is no +/// text part, append one. Mirrors what `MessagePayload`'s normal +/// modify-path does for single-view v0. +fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) { + for part in msg.content.iter_mut() { + if let cpex_core::cmf::ContentPart::Text { text } = part { + *text = new_text.to_string(); + return; + } + } + msg.content.push(cpex_core::cmf::ContentPart::Text { + text: new_text.to_string(), + }); +} + +/// Extract `RoutePayload.args` from a CMF message. v0 maps: +/// * First `ContentPart::ToolCall` → `arguments` map (Object) +/// * First `ContentPart::PromptRequest` → `arguments` map (Object) +/// * Else (text / no entity parts) → JSON String of text content +/// +/// `args.` APL paths target tool / prompt arguments directly. +/// For text-only messages we fall back to the v0 "args = whole text" +/// shape so `args.text` predicates keep working. +fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value { + use cpex_core::cmf::ContentPart; + for part in &msg.content { + match part { + ContentPart::ToolCall { content } => { + return Value::Object( + content + .arguments + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ); + } + ContentPart::PromptRequest { content } => { + return Value::Object( + content + .arguments + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ); + } + _ => {} + } + } + Value::String(msg.get_text_content()) +} + +/// Inverse of [`extract_args_from_message`]: write `args` back into +/// `msg`'s first ToolCall / PromptRequest argument map, or — for +/// text payloads — into the first text part. +/// +/// Silently no-ops when the args shape doesn't match the message +/// content shape (e.g. operator pipeline produced a String for what +/// was originally a ToolCall). The mismatch path is recoverable — +/// the upstream just sees the original unmodified content rather +/// than a malformed rewrite. +fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) { + use cpex_core::cmf::ContentPart; + for part in msg.content.iter_mut() { + match part { + ContentPart::ToolCall { content } => { + if let Some(obj) = args.as_object() { + content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + } + return; + } + ContentPart::PromptRequest { content } => { + if let Some(obj) = args.as_object() { + content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + } + return; + } + _ => {} + } + } + // Fall through: no structured entity part — treat as text. + if let Some(text) = args.as_str() { + rewrite_message_text(msg, text); + } +} + +/// Extract `RoutePayload.result` from a CMF message. Mirror of +/// [`extract_args_from_message`] for the Post phase. v0 maps: +/// * First `ContentPart::ToolResult` → its `content` JSON value +/// * Else (text / no structured result part) → JSON String of text +/// +/// `result.` APL paths target the structured result directly. +fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value { + use cpex_core::cmf::ContentPart; + for part in &msg.content { + if let ContentPart::ToolResult { content } = part { + return content.content.clone(); + } + } + Value::String(msg.get_text_content()) +} + +/// Inverse of [`extract_result_from_message`]: write a mutated +/// `result` back into the message's first `ContentPart::ToolResult.content`, +/// or — for text-only messages — into the first text part. The praxis +/// filter's response-body re-serializer then lifts the new content +/// out of the ContentPart and folds it back into the JSON-RPC +/// `result.content[*].text` payload. +fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) { + use cpex_core::cmf::ContentPart; + for part in msg.content.iter_mut() { + if let ContentPart::ToolResult { content } = part { + content.content = result.clone(); + return; + } + } + if let Some(text) = result.as_str() { + rewrite_message_text(msg, text); + } +} + +/// Cheap pointer-equality check across the few mutable extension slots +/// the executor would care about. False positives (claiming a change +/// when there isn't one) are cheap — the executor re-validates anyway. +fn extensions_changed(before: &Extensions, after: &Extensions) -> bool { + let security_changed = match (before.security.as_ref(), after.security.as_ref()) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + // `delegate(...)` steps write minted tokens into + // `raw_credentials.delegated_tokens` via the shared Mutex — + // without this check, a route whose only Extensions mutation is + // a delegate (no security / delegation chain edit) looks + // unchanged, so the executor never merges the minted token back + // and downstream readers (our HttpFilter attaching the token to + // the upstream request) see nothing. + let raw_creds_changed = match ( + before.raw_credentials.as_ref(), + after.raw_credentials.as_ref(), + ) { + (Some(a), Some(b)) => !Arc::ptr_eq(a, b), + (None, None) => false, + _ => true, + }; + security_changed || delegation_changed || raw_creds_changed +} + diff --git a/crates/apl-cpex/src/session_resolver.rs b/crates/apl-cpex/src/session_resolver.rs new file mode 100644 index 00000000..77615c96 --- /dev/null +++ b/crates/apl-cpex/src/session_resolver.rs @@ -0,0 +1,432 @@ +// Location: ./crates/apl-cpex/src/session_resolver.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// 3-tier session-id resolver. The Python apl-plugins `SessionResolver` +// (cpex/framework/session.py) shipped a 4-tier version including a +// client-supplied `X-CPEX-Session-Id` header tier. **That tier is +// excluded by design here**: an authenticated client can set the +// header to another subject's known session id and inherit their +// accumulated taint labels, or to a new value and escape their own +// tainted session — defeating `session.labels`-based deny policies +// entirely. The Python comment framed the header as a feature ("lets +// a smart client maintain its own session boundary"); under threat +// modeling it is a privilege-escalation channel with no surviving +// use case the other tiers don't cover. If a future deployment needs +// client-supplied session grouping, the right shape is a subject- +// bound hash (`sha256(subject_id : client_value)`), not the raw +// header value. +// +// The resolver walks these tiers in order, returning the first hit: +// +// 0. `agent` — `AgentExtension.session_id`. A *pre-resolved* +// value: an upstream plugin or middleware decided what the +// session is and wrote it here. Highest priority because it +// represents authority, not derivation — overriding this with a +// derived value would discard that upstream decision. Plugins +// that need bespoke session resolution (e.g., reading from a +// separate session-management service) write here and let the +// resolver pick it up. +// +// 1. `token_claim` — explicit `session_id` claim in the inbound JWT. +// Strongest binding among the *derived* tiers: the auth issuer +// chose this session and signed it into the token. Read from +// `SecurityExtension.subject.claims["session_id"]`. +// +// 2. `identity` — derived: sha256(sub : caller_workload : this_workload)[:16]. +// No special infrastructure needed; the triple is already populated +// by `apl-identity-jwt`'s claim mapping. Same user + same agent + +// same gateway = same session, stable across token refresh (the +// claims are stable even when the token string isn't). +// +// 3. `none` — no usable identifier; caller (CmfPluginInvoker) +// skips hydration / persistence. Returns `Ok(None)` so the caller +// can distinguish "no session" from "resolver error" if we ever +// add an error variant. +// +// Each tier reads from a typed `Extensions` field, not raw JWT/HTTP +// payloads — those have already been mapped by upstream identity +// plugins (apl-identity-jwt). The resolver stays free of crypto / +// parsing logic. + +use cpex_core::extensions::Extensions; +use sha2::{Digest, Sha256}; + +/// Which tier produced the session id. Useful for diagnostics / audit +/// and to let downstream code branch on binding strength (e.g., only +/// trust `token_claim`-derived sessions for the highest-stakes +/// operations). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionSource { + /// Pre-resolved by an upstream plugin via `AgentExtension.session_id`. + /// Highest priority — represents an authoritative decision. + Agent, + /// JWT `session_id` claim — strongest binding among derived tiers. + TokenClaim, + /// Derived from the identity triple. Stable across token refresh. + Identity, +} + +impl SessionSource { + pub fn as_str(self) -> &'static str { + match self { + SessionSource::Agent => "agent", + SessionSource::TokenClaim => "token_claim", + SessionSource::Identity => "identity", + } + } +} + +/// Resolve a session id from the request's `Extensions`. Returns +/// `Some((id, source))` on the first tier that hits, or `None` when +/// every tier comes up empty (anonymous request, no claims, no +/// header, no identity). +/// +/// Identity-tier (2) requires at minimum `security.subject.id` to be +/// populated — without an end-user identifier there's no meaningful +/// session boundary to hash against. The other two identity-triple +/// components (caller_workload, this_workload) fall back to the +/// `"-"` sentinel when absent, which keeps the hash defined but +/// degrades to a (sub, *, *) session — usually fine for demos with +/// a single gateway and single agent. +pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> { + // Tier 0: pre-resolved by an upstream plugin. Authoritative — + // wins over every derived tier so plugin-supplied custom session + // resolution isn't silently overridden by a derived hash. + if let Some(agent) = ext.agent.as_deref() { + if let Some(sid) = agent.session_id.as_deref() { + if !sid.is_empty() { + return Some((sid.to_string(), SessionSource::Agent)); + } + } + } + + // Tier 1: explicit JWT claim. + if let Some(sec) = ext.security.as_deref() { + if let Some(subj) = sec.subject.as_ref() { + if let Some(sid) = subj.claims.get("session_id") { + if !sid.is_empty() { + return Some((sid.clone(), SessionSource::TokenClaim)); + } + } + } + } + + // Tier 2: identity-derived. Hash the triple + // (end-user : calling agent : our gateway) — stable across token + // refresh because all three components survive token rotation. + if let Some(sec) = ext.security.as_deref() { + let sub = sec.subject.as_ref().and_then(|s| s.id.as_deref()); + if let Some(sub) = sub { + // Fall back to `-` so a missing component degrades the + // session to (sub, *, *) rather than the resolver silently + // returning None. Important for demos where the gateway + // hasn't yet attested its own `this_workload` identity. + let actor = sec + .caller_workload + .as_ref() + .and_then(|w| w.client_id.as_deref()) + .unwrap_or("-"); + let aud = sec + .this_workload + .as_ref() + .and_then(|w| w.client_id.as_deref()) + .unwrap_or("-"); + let raw = format!("{}:{}:{}", sub, actor, aud); + let mut hasher = Sha256::new(); + hasher.update(raw.as_bytes()); + // 16 hex chars = 64 bits — plenty for the workload sizes + // CPEX targets, matches the Python implementation's + // `hexdigest()[:16]`. + let digest = hasher.finalize(); + let hex: String = digest + .iter() + .take(8) + .map(|b| format!("{:02x}", b)) + .collect(); + return Some((hex, SessionSource::Identity)); + } + } + + // Tier 3: no session. + None +} + +// ===================================================================== +// Tests — one scenario per tier, plus tier-priority assertions. +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::extensions::{ + AgentExtension, Extensions, HttpExtension, SecurityExtension, SubjectExtension, + WorkloadIdentity, + }; + use std::sync::Arc; + + fn extensions_with_security(sec: SecurityExtension) -> Extensions { + Extensions { + security: Some(Arc::new(sec)), + ..Default::default() + } + } + + fn subject_with_claims(id: Option<&str>, claims: &[(&str, &str)]) -> SubjectExtension { + SubjectExtension { + id: id.map(String::from), + claims: claims + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ..Default::default() + } + } + + // --- Tier 0: agent (pre-resolved) --- + + #[test] + fn tier0_agent_session_id_hits_first() { + let mut agent = AgentExtension::default(); + agent.session_id = Some("sess-upstream".into()); + let ext = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let (sid, src) = resolve_session(&ext).expect("should resolve"); + assert_eq!(sid, "sess-upstream"); + assert_eq!(src, SessionSource::Agent); + } + + #[test] + fn tier0_skips_empty_agent_session_id() { + // Empty agent.session_id should fall through, otherwise an + // upstream that accidentally cleared the slot aliases every + // such request to "". + let mut agent = AgentExtension::default(); + agent.session_id = Some("".into()); + let ext = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + assert!(resolve_session(&ext).is_none()); + } + + #[test] + fn tier0_wins_over_token_claim() { + // Pre-resolved value beats a JWT claim — upstream authority. + let mut agent = AgentExtension::default(); + agent.session_id = Some("from-agent".into()); + let sec = SecurityExtension { + subject: Some(subject_with_claims( + Some("alice"), + &[("session_id", "from-token")], + )), + ..Default::default() + }; + let ext = Extensions { + agent: Some(Arc::new(agent)), + security: Some(Arc::new(sec)), + ..Default::default() + }; + + let (sid, src) = resolve_session(&ext).unwrap(); + assert_eq!(sid, "from-agent"); + assert_eq!(src, SessionSource::Agent); + } + + // --- Tier 1: token_claim --- + + #[test] + fn tier1_token_claim_hits_when_session_id_claim_present() { + let sec = SecurityExtension { + subject: Some(subject_with_claims( + Some("alice@corp.com"), + &[("session_id", "sess-from-token-789")], + )), + ..Default::default() + }; + let ext = extensions_with_security(sec); + + let (sid, src) = resolve_session(&ext).expect("should resolve"); + assert_eq!(sid, "sess-from-token-789"); + assert_eq!(src, SessionSource::TokenClaim); + } + + #[test] + fn tier1_skips_empty_session_id_claim() { + // Empty claim values should NOT win tier 1 — they degrade to + // identity-derived. Otherwise an issuer accidentally putting + // an empty string in the claim would yield "" as the session + // key, which would alias every such request. + let sec = SecurityExtension { + subject: Some(subject_with_claims( + Some("alice"), + &[("session_id", "")], + )), + ..Default::default() + }; + let ext = extensions_with_security(sec); + + let (_, src) = resolve_session(&ext).expect("should fall through to identity"); + assert_eq!(src, SessionSource::Identity); + } + + // --- Tier 2 (`X-CPEX-Session-Id` header) is intentionally absent --- + // + // The Python `SessionResolver` included a header tier; cpex Rust + // does not. See the module-level doc comment for the threat model. + // A spoofing-regression guard lives below in + // `header_x_cpex_session_id_is_ignored`. + + // --- Tier 2: identity --- + + #[test] + fn tier2_identity_derived_when_no_claim() { + let sec = SecurityExtension { + subject: Some(subject_with_claims(Some("alice@corp.com"), &[])), + caller_workload: Some(WorkloadIdentity { + client_id: Some("agent-007".into()), + ..Default::default() + }), + this_workload: Some(WorkloadIdentity { + client_id: Some("praxis-gateway".into()), + ..Default::default() + }), + ..Default::default() + }; + let ext = extensions_with_security(sec); + + let (sid, src) = resolve_session(&ext).expect("should resolve"); + assert_eq!(src, SessionSource::Identity); + // 16 hex chars (matches Python `sha256(...)[:16]`). + assert_eq!(sid.len(), 16); + assert!(sid.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn tier2_identity_is_stable_across_calls() { + // Same triple → same session id. Property guarantees that + // a token refresh (which doesn't change sub/caller/this) keeps + // the session intact. + let mk = || -> SecurityExtension { + SecurityExtension { + subject: Some(subject_with_claims(Some("alice@corp.com"), &[])), + caller_workload: Some(WorkloadIdentity { + client_id: Some("agent-007".into()), + ..Default::default() + }), + this_workload: Some(WorkloadIdentity { + client_id: Some("praxis-gateway".into()), + ..Default::default() + }), + ..Default::default() + } + }; + let ext1 = extensions_with_security(mk()); + let ext2 = extensions_with_security(mk()); + let (sid1, _) = resolve_session(&ext1).unwrap(); + let (sid2, _) = resolve_session(&ext2).unwrap(); + assert_eq!(sid1, sid2); + } + + #[test] + fn tier2_distinguishes_different_users() { + let alice = SecurityExtension { + subject: Some(subject_with_claims(Some("alice"), &[])), + ..Default::default() + }; + let bob = SecurityExtension { + subject: Some(subject_with_claims(Some("bob"), &[])), + ..Default::default() + }; + let (sid_a, _) = resolve_session(&extensions_with_security(alice)).unwrap(); + let (sid_b, _) = resolve_session(&extensions_with_security(bob)).unwrap(); + assert_ne!(sid_a, sid_b); + } + + #[test] + fn tier2_distinguishes_different_agents() { + // Same user, two different agents → different sessions. + // Important so a malicious agent's accumulated taints don't + // affect a different agent that user runs. + let mk = |agent: &str| -> SecurityExtension { + SecurityExtension { + subject: Some(subject_with_claims(Some("alice"), &[])), + caller_workload: Some(WorkloadIdentity { + client_id: Some(agent.into()), + ..Default::default() + }), + ..Default::default() + } + }; + let (sid1, _) = resolve_session(&extensions_with_security(mk("agent-a"))).unwrap(); + let (sid2, _) = resolve_session(&extensions_with_security(mk("agent-b"))).unwrap(); + assert_ne!(sid1, sid2); + } + + // --- Tier 3: none --- + + #[test] + fn tier3_no_session_when_no_data() { + let ext = Extensions::default(); + assert!(resolve_session(&ext).is_none()); + } + + #[test] + fn tier3_no_session_when_no_subject_id() { + // Security exists but no subject id → identity can't hash. + // Claim is absent too. Should be None. + let sec = SecurityExtension { + subject: Some(SubjectExtension::default()), // id = None + ..Default::default() + }; + let ext = extensions_with_security(sec); + assert!(resolve_session(&ext).is_none()); + } + + // --- Spoofing guard (regression test for P0-2) --- + + #[test] + fn header_x_cpex_session_id_is_ignored() { + // The Python apl-plugins resolver honored an `X-CPEX-Session-Id` + // header tier between token_claim and identity. We deliberately + // dropped it: an authenticated client could set the header to + // another subject's session id and inherit their accumulated + // taints, or to a random unused value and escape their own + // tainted session. This test pins that behaviour: the header is + // present, no token claim exists, and the resolver still falls + // through to identity-derived (or none) rather than honoring + // the header. If a future PR adds a header tier without + // subject binding, this test fails. + let sec = SecurityExtension { + subject: Some(subject_with_claims(Some("alice"), &[])), + caller_workload: Some(WorkloadIdentity { + client_id: Some("agent-007".into()), + ..Default::default() + }), + ..Default::default() + }; + let mut http = HttpExtension::default(); + http.request_headers + .insert("X-CPEX-Session-Id".into(), "sess-bob-stolen".into()); + let ext = Extensions { + security: Some(Arc::new(sec)), + http: Some(Arc::new(http)), + ..Default::default() + }; + + let (sid, src) = resolve_session(&ext).expect("identity should still hit"); + assert_eq!( + src, + SessionSource::Identity, + "header tier was removed; resolver must NOT honor X-CPEX-Session-Id", + ); + assert_ne!( + sid, "sess-bob-stolen", + "header value must never become the session id", + ); + } +} diff --git a/crates/apl-cpex/src/session_store.rs b/crates/apl-cpex/src/session_store.rs new file mode 100644 index 00000000..54f70378 --- /dev/null +++ b/crates/apl-cpex/src/session_store.rs @@ -0,0 +1,157 @@ +// Location: ./crates/apl-cpex/src/session_store.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `SessionStore` — pluggable backend for cross-request session state. +// v0 surface is intentionally tiny: monotonic label append + load. That +// covers `extensions.security.labels` persistence, which is the only +// session-scoped state APL needs today. +// +// # Why a trait +// +// State that survives between requests in the same session (accumulated +// taint labels, delegation history, conversation context) needs to be +// pluggable: in-memory for tests and single-process deployments, Redis +// or DynamoDB for distributed ones. The previous Python implementation +// had a `SessionState` abstraction with the same shape; this is the +// Rust port. Only the labels surface lands in v0 — delegation hops, +// conversation history, and arbitrary KV come when their consumers do. +// +// # String-typed deliberately +// +// The trait stays string-typed (`Vec` for labels) rather than +// reaching into cpex-core's `MonotonicSet` so non-CMF bridges +// (future apl-mcp, apl-langgraph, etc.) can reuse it without dragging +// CPEX types into their surface. `CmfPluginInvoker` does the +// hydration/persistence into/out of `Extensions.security.labels`. + +use std::collections::{HashMap, HashSet}; +use std::sync::RwLock; + +use async_trait::async_trait; + +/// Pluggable session-state backend. Implementations must be `Send + Sync` +/// — the same store is shared across all concurrent requests. +/// +/// Invariants: +/// - `append_labels` is **monotonic** — labels added to a session never +/// come back out. Removal (declassification) is a separate operation +/// not covered by v0. +/// - Empty `load_labels` for an unknown `session_id` is the right +/// response — non-session traffic shouldn't fail, it just sees no +/// accumulated state. +#[async_trait] +pub trait SessionStore: Send + Sync { + /// Load the union of labels accumulated for the session. Empty for + /// new or unknown sessions. + async fn load_labels(&self, session_id: &str) -> Vec; + + /// Append labels to the session. Existing labels are kept; new ones + /// are unioned in. Caller has already deduped against `load_labels` + /// in the hot path, but the store re-dedups defensively. + async fn append_labels(&self, session_id: &str, labels: &[String]); +} + +/// In-process `SessionStore` backed by a `HashMap` of `HashSet`s. Suitable +/// for tests, single-process deployments, and as the default when no +/// distributed store is configured. Cloning the store via `Arc` shares +/// state across all consumers. +#[derive(Default)] +pub struct MemorySessionStore { + /// `RwLock` because reads (load_labels at request start) outnumber + /// writes (append at request end) in steady state — and lock + /// contention is bounded by the per-session level of concurrency, + /// not request volume. + inner: RwLock>>, +} + +impl MemorySessionStore { + pub fn new() -> Self { + Self::default() + } + + /// Snapshot the entire store. Test/diagnostic helper — production + /// callers should go through the trait so the backing implementation + /// stays swappable. + pub fn snapshot(&self) -> HashMap> { + self.inner + .read() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } +} + +#[async_trait] +impl SessionStore for MemorySessionStore { + async fn load_labels(&self, session_id: &str) -> Vec { + let r = self.inner.read().unwrap_or_else(|p| p.into_inner()); + r.get(session_id) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default() + } + + async fn append_labels(&self, session_id: &str, labels: &[String]) { + if labels.is_empty() { + return; + } + let mut w = self.inner.write().unwrap_or_else(|p| p.into_inner()); + let entry = w.entry(session_id.to_string()).or_default(); + for l in labels { + entry.insert(l.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[tokio::test] + async fn load_for_unknown_session_is_empty() { + let store = MemorySessionStore::new(); + assert!(store.load_labels("nonexistent").await.is_empty()); + } + + #[tokio::test] + async fn append_then_load_roundtrips() { + let store = MemorySessionStore::new(); + store + .append_labels("sess-1", &["PII".to_string(), "INTERNAL".to_string()]) + .await; + let mut labels = store.load_labels("sess-1").await; + labels.sort(); + assert_eq!(labels, vec!["INTERNAL".to_string(), "PII".to_string()]); + } + + #[tokio::test] + async fn append_is_monotonic_dedupes() { + let store = MemorySessionStore::new(); + store.append_labels("sess-1", &["PII".to_string()]).await; + store + .append_labels("sess-1", &["PII".to_string(), "PII".to_string()]) + .await; + let labels = store.load_labels("sess-1").await; + assert_eq!(labels.len(), 1); + assert_eq!(labels[0], "PII"); + } + + #[tokio::test] + async fn sessions_are_isolated() { + let store = MemorySessionStore::new(); + store.append_labels("a", &["X".to_string()]).await; + store.append_labels("b", &["Y".to_string()]).await; + assert_eq!(store.load_labels("a").await, vec!["X".to_string()]); + assert_eq!(store.load_labels("b").await, vec!["Y".to_string()]); + } + + #[tokio::test] + async fn shared_arc_observes_writes() { + let store: Arc = Arc::new(MemorySessionStore::new()); + let c1 = Arc::clone(&store); + let c2 = Arc::clone(&store); + c1.append_labels("sess", &["Z".to_string()]).await; + assert_eq!(c2.load_labels("sess").await, vec!["Z".to_string()]); + } +} diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs new file mode 100644 index 00000000..cf9eea71 --- /dev/null +++ b/crates/apl-cpex/src/visitor.rs @@ -0,0 +1,680 @@ +// Location: ./crates/apl-cpex/src/visitor.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `AplConfigVisitor` — the cpex-core `ConfigVisitor` implementation that +// stacks the unified-config hierarchy (global → defaults → tag bundles +// → routes) into a single `CompiledRoute` per route and installs an +// [`AplRouteHandler`] for each phase via `PluginManager::annotate_route`. +// +// # Hierarchy stacking +// +// Each `visit_*` call carries a single block of raw YAML. The visitor +// finds the `apl:` sub-block (if any), compiles it to a `CompiledRoute`, +// and stashes it in interior state: +// +// visit_global → state.global_layer +// visit_default → state.default_layers[entity_type] +// visit_policy_bundle → state.tag_layers[tag] +// visit_route → build effective route by layering and annotate. +// +// At `visit_route` we layer least-to-most-specific: +// +// effective = global +// effective.apply_layer(default_layer_for(entity_type)) +// for tag in route.meta.tags { effective.apply_layer(tag_layer(tag)) } +// effective.apply_layer(route_apl_block) +// +// then construct one `AplRouteHandler` per phase (Pre, Post) and call +// `annotate_route` for each `(entity_type, entity_name, scope, hook)`. +// +// # Hook names per entity type +// +// Each entity type binds to its own CMF hook pair: +// +// * `tool:` → `cmf.tool_pre_invoke` / `cmf.tool_post_invoke` +// * `llm:` → `cmf.llm_input` / `cmf.llm_output` +// * `prompt:` → `cmf.prompt_pre_invoke` / `cmf.prompt_post_invoke` +// * `resource:` → `cmf.resource_pre_fetch` / `cmf.resource_post_fetch` +// +// The mapping lives in [`hook_pair_for_entity`]. Hosts fire +// `mgr.invoke_named::("cmf.llm_input", ...)` for LLM +// invocations; the visitor's annotation on `cmf.llm_input` for the +// matching route's entity_name is what AplRouteHandler intercepts. +// +// `tool_pre_invoke` / `tool_post_invoke` are exposed as legacy +// re-exports for callers that wired against the v0 constants — the +// per-entity dispatch is the load-bearing path now. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock, Weak}; + +use cpex_core::cmf::constants::{ + ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, HOOK_CMF_LLM_INPUT, + HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, HOOK_CMF_PROMPT_PRE_INVOKE, + HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_TOOL_POST_INVOKE, + HOOK_CMF_TOOL_PRE_INVOKE, +}; +use cpex_core::config::RouteEntry; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::PluginConfig; +use cpex_core::visitor::{ConfigVisitor, VisitorError}; + +use apl_core::parser::compile_policy_block_value; +use apl_core::plugin_decl::{PluginDeclaration, PluginRegistry}; +use apl_core::rules::CompiledRoute; +use apl_core::step::{PdpFactory, PdpResolver}; + +use crate::dispatch_plan::DispatchCache; +use crate::pdp_router::PdpRouter; +use crate::route_handler::{AplRouteHandler, Phase}; +use crate::session_store::SessionStore; + +/// Legacy alias for the tool-family pre hook. Kept exported for +/// callers that wired against the v0 visitor constants — the +/// per-entity-type dispatch via `hook_pair_for_entity` is the +/// load-bearing path now. +pub const HOOK_PRE: &str = HOOK_CMF_TOOL_PRE_INVOKE; +/// Legacy alias for the tool-family post hook. See `HOOK_PRE`. +pub const HOOK_POST: &str = HOOK_CMF_TOOL_POST_INVOKE; + +/// Resolve the (pre, post) CMF hook pair for an entity_type. Drives +/// per-entity `annotate_route` calls so an `llm:` route annotates on +/// `cmf.llm_input` / `cmf.llm_output` rather than the tool-family +/// hooks. Returns `None` for unknown entity types — the visitor logs +/// + skips those routes. +fn hook_pair_for_entity(entity_type: &str) -> Option<(&'static str, &'static str)> { + match entity_type { + ENTITY_TOOL => Some((HOOK_CMF_TOOL_PRE_INVOKE, HOOK_CMF_TOOL_POST_INVOKE)), + ENTITY_LLM => Some((HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT)), + ENTITY_PROMPT => Some((HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_PROMPT_POST_INVOKE)), + ENTITY_RESOURCE => Some((HOOK_CMF_RESOURCE_PRE_FETCH, HOOK_CMF_RESOURCE_POST_FETCH)), + _ => None, + } +} + +/// Interior state accumulated as the manager walks the visitor. +/// `plugin_registry` is populated by `visit_plugins` (called once per +/// load); the layer fields are populated as the visitor walks +/// `global` / `defaults` / `policies` / `routes`; `pdp_router` is +/// populated by both code-supplied resolvers (`register_pdp`) and +/// unified-config-driven entries under `global.apl.pdp[]` (built +/// during `visit_global`). +#[derive(Default)] +struct VisitorState { + plugin_registry: PluginRegistry, + global_layer: Option, + default_layers: HashMap, + tag_layers: HashMap, + pdp_router: PdpRouter, +} + +/// APL implementation of [`cpex_core::visitor::ConfigVisitor`]. Construct +/// once per host with the shared infrastructure (dispatch cache, session +/// store, manager handle) and register with `PluginManager::register_visitor` +/// before calling `load_config_yaml`. +/// +/// PDPs come from two sources, both feeding the same internal +/// [`PdpRouter`]: +/// +/// 1. **Code-supplied** via `register_pdp` (or `AplOptions.pdps`) — +/// the host built the resolver in code and hands it in. +/// 2. **Config-supplied** via `global.apl.pdp[]` blocks in the unified +/// config — the visitor sees the block, looks up a factory by +/// `kind`, and constructs the resolver during `visit_global`. +/// +/// Factories are registered up front by `kind` name (`"cedar-direct"`, +/// `"cedarling"`, …). The visitor knows nothing about specific PDP +/// backends; everything dispatches through `PdpFactory`. +pub struct AplConfigVisitor { + state: RwLock, + dispatch_cache: Arc, + session_store: Arc, + manager: Weak, + /// Baseline capabilities granted to every synthetic `AplRouteHandler` + /// the visitor installs. Unioned with the per-route plugin + /// capability set so APL predicates that touch extensions + /// (`require(authenticated)` needs `read_subject`, etc.) work even + /// when no plugins are referenced. Hosts that want strict gating + /// can set this to an empty set. + base_capabilities: std::collections::HashSet, + /// Factories the visitor consults when it encounters a + /// `global.apl.pdp[]` entry. Keyed by the factory's `kind()` — + /// matches the `kind:` field in the YAML block. + pdp_factories: HashMap>, +} + +impl AplConfigVisitor { + pub fn new( + dispatch_cache: Arc, + session_store: Arc, + manager: Weak, + ) -> Self { + Self { + state: RwLock::new(VisitorState::default()), + dispatch_cache, + session_store, + manager, + base_capabilities: default_base_capabilities(), + pdp_factories: HashMap::new(), + } + } + + /// Register a code-supplied PDP resolver. Equivalent to declaring a + /// PDP in the unified config but for hosts that prefer wiring + /// resolvers in Rust. Resolvers are pushed into the internal + /// `PdpRouter`; the first registration per dialect wins (matches + /// `PdpRouter::register` semantics). + pub fn register_pdp(&self, resolver: Arc) { + let mut state = self.state.write().unwrap_or_else(|p| p.into_inner()); + state.pdp_router.register(resolver); + } + + /// Register a PDP factory by its `kind()`. Called during + /// `register_apl` setup; the visitor uses these to instantiate + /// resolvers from `global.apl.pdp[]` config blocks. + pub fn register_pdp_factory(&mut self, factory: Arc) { + self.pdp_factories.insert(factory.kind().to_string(), factory); + } + + /// Replace the baseline capability set granted to every installed + /// `AplRouteHandler`. Default covers read-only attributes APL + /// predicates commonly touch (subject, role, labels, delegation, + /// agent). Tighten this when the deployment's policy plugins + /// don't need broad reads — every cap removed is one fewer + /// extension slot a buggy predicate can leak through. + pub fn with_base_capabilities( + mut self, + caps: std::collections::HashSet, + ) -> Self { + self.base_capabilities = caps; + self + } + + /// Parse one entry from `global.apl.pdp[]`. Reads `kind`, dispatches + /// to the matching factory, installs the resulting resolver into + /// the internal `PdpRouter`. Called per entry during `visit_global`. + /// + /// `index` is used only for diagnostics — operators see "the third + /// pdp entry failed" rather than a generic "a pdp entry failed." + fn build_pdp_from_config( + &self, + entry: &serde_yaml::Value, + index: usize, + ) -> Result<(), VisitorError> { + let map = entry.as_mapping().ok_or_else(|| { + format!( + "global.apl.pdp[{}] must be a mapping with a `kind:` field", + index + ) + })?; + let kind = map + .get(serde_yaml::Value::String("kind".to_string())) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + format!( + "global.apl.pdp[{}] missing required `kind:` field", + index + ) + })?; + let factory = self.pdp_factories.get(kind).ok_or_else(|| { + format!( + "global.apl.pdp[{}] declared kind='{}' but no factory is registered for that kind — \ + host must call register_pdp_factory(...) before load_config_yaml", + index, kind + ) + })?; + let resolver = factory.build(entry).map_err(|e| { + format!( + "global.apl.pdp[{}] (kind='{}') failed to build: {}", + index, kind, e + ) + })?; + let mut state = self.state.write().unwrap_or_else(|p| p.into_inner()); + state.pdp_router.register(resolver); + Ok(()) + } +} + +/// Read-only baseline for APL predicates: enough to make +/// `authenticated`, `role.*`, `perm.*`, `subject.*`, `claim.*`, +/// `subject.teams`, `security.labels`, `delegated`, `delegation.*`, +/// and `agent.*` evaluate correctly. Excludes all *write* capabilities +/// — those are granted on demand by the per-route plugin union when a +/// plugin declares `append_labels` / `append_delegation` / +/// `write_headers`. +/// +/// `read_subject` alone unlocks only `subject.id` / `subject.type`; +/// roles, permissions, teams, and claims are each gated by their own +/// capability (`read_roles` / `read_permissions` / `read_teams` / +/// `read_claims`). PDP-driven policies routinely read principal.roles / +/// principal.claims, so the baseline grants all four — tightening +/// further would surprise APL authors whose `cedar:` policies suddenly +/// see empty role sets in deployments with no plugin-declared caps. +/// Hosts that want strict subject access override this via +/// `AplOptions.base_capabilities`. +fn default_base_capabilities() -> std::collections::HashSet { + [ + "read_subject", + "read_roles", + "read_permissions", + "read_teams", + "read_claims", + "read_labels", + "read_delegation", + "read_agent", + "read_meta", + ] + .iter() + .map(|s| s.to_string()) + .collect() +} + +impl ConfigVisitor for AplConfigVisitor { + fn name(&self) -> &str { + "apl" + } + + fn visit_plugins( + &self, + _mgr: &Arc, + plugins: &[PluginConfig], + ) -> Result<(), VisitorError> { + // Translate cpex-core's typed PluginConfig into apl-core's + // PluginDeclaration. Field-for-field except `capabilities` is a + // `HashSet` on the cpex side and a `Vec` on the apl side, and + // `config` is wrapped in `serde_yaml::Value::Mapping` to match + // apl-core's opaque shape. cpex-core has already validated + // uniqueness by this point so we don't re-check. + let mut state = self.state.write().unwrap_or_else(|p| p.into_inner()); + state.plugin_registry.clear(); + for cfg in plugins { + let decl = PluginDeclaration { + name: cfg.name.clone(), + kind: cfg.kind.clone(), + hooks: cfg.hooks.clone(), + capabilities: cfg.capabilities.iter().cloned().collect(), + config: plugin_config_to_yaml(&cfg.config), + on_error: Some(on_error_to_string(&cfg.on_error)), + extra: HashMap::new(), + }; + state.plugin_registry.insert(cfg.name.clone(), decl); + } + Ok(()) + } + + fn visit_global( + &self, + _mgr: &Arc, + yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + let Some(apl_block) = apl_subblock(yaml) else { + return Ok(()); + }; + + // Process `apl.pdp[]` before stacking the policy/post_policy + // layer — route handlers that reference PDPs need them + // resolvable by the time `visit_route` runs. + if let Some(pdp_entries) = apl_block.get("pdp").and_then(|v| v.as_sequence()) { + for (i, entry) in pdp_entries.iter().enumerate() { + self.build_pdp_from_config(entry, i)?; + } + } + + // The `pdp:` sub-key isn't an APL DSL field; strip it before + // handing the block to `compile_policy_block_value` so the + // compiler doesn't see an unknown key. `compile_policy_block_value` + // accepts maps with `policy:` / `post_policy:` / `args:` / + // `result:` / `plugins:` (and inert fields it ignores), so a + // shallow strip on a clone is enough. + let policy_only = strip_pdp_key(apl_block); + let compiled = compile_policy_block_value("global.apl", &policy_only) + .map_err(|e| Box::new(e) as VisitorError)?; + self.state + .write() + .unwrap_or_else(|p| p.into_inner()) + .global_layer = Some(compiled); + Ok(()) + } + + fn visit_default( + &self, + _mgr: &Arc, + entity_type: &str, + yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + let Some(apl_block) = apl_subblock(yaml) else { + return Ok(()); + }; + let source = format!("global.defaults.{}.apl", entity_type); + let compiled = compile_policy_block_value(&source, apl_block) + .map_err(|e| Box::new(e) as VisitorError)?; + self.state + .write() + .unwrap_or_else(|p| p.into_inner()) + .default_layers + .insert(entity_type.to_string(), compiled); + Ok(()) + } + + fn visit_policy_bundle( + &self, + _mgr: &Arc, + tag: &str, + yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + let Some(apl_block) = apl_subblock(yaml) else { + return Ok(()); + }; + let source = format!("global.policies.{}.apl", tag); + let compiled = compile_policy_block_value(&source, apl_block) + .map_err(|e| Box::new(e) as VisitorError)?; + self.state + .write() + .unwrap_or_else(|p| p.into_inner()) + .tag_layers + .insert(tag.to_string(), compiled); + Ok(()) + } + + fn visit_route( + &self, + mgr: &Arc, + yaml: &serde_yaml::Value, + parsed: &RouteEntry, + ) -> Result<(), VisitorError> { + // Extract the route's APL block (if any) and the entity identity + // we need for annotate_route. A route without an APL block AND + // without inherited layers contributes nothing — skip. + let route_apl = apl_subblock(yaml); + let (entity_type, entity_names) = match entity_identity(parsed) { + Some(e) => e, + None => { + tracing::warn!( + "APL visitor: route has no tool/resource/prompt/llm match — skipping", + ); + return Ok(()); + } + }; + let scope = parsed.meta.as_ref().and_then(|m| m.scope.clone()); + let tags: Vec = parsed + .meta + .as_ref() + .map(|m| m.tags.clone()) + .unwrap_or_default(); + + // Snapshot the plugin registry + PDP router once outside the + // per-entity loop. `visit_plugins` populated the registry + // before any `visit_route` call; the router has been populated + // by code-supplied `register_pdp` calls + `visit_global` + // factory dispatch. Routes share both, so cloning each into an + // `Arc` once and handing clones to each handler is cheaper than + // re-reading the RwLock per entity. Cloning `PdpRouter` is + // refcount bumps on each inner resolver — cheap. + let (plugin_registry, pdp_router_arc) = { + let state = self.state.read().unwrap_or_else(|p| p.into_inner()); + ( + Arc::new(state.plugin_registry.clone()), + Arc::new(state.pdp_router.clone()) as Arc, + ) + }; + + for entity_name in &entity_names { + // route_key is what `DispatchCache` keys on, so it must + // disambiguate scoped vs unscoped routes for the same + // entity — otherwise two same-named annotations share one + // cached plan and the second's overrides leak into the first. + let route_key = match &scope { + Some(s) => format!("{}:{}@{}", entity_type, entity_name, s), + None => format!("{}:{}", entity_type, entity_name), + }; + let state = self.state.read().unwrap_or_else(|p| p.into_inner()); + + // Stack least-to-most-specific. Each apply_layer call appends + // policy/post_policy steps and merges args/result/plugin_overrides + // by field; the resulting CompiledRoute represents the route's + // effective policy in evaluation order. + let mut effective = CompiledRoute::new(&route_key); + if let Some(layer) = state.global_layer.clone() { + effective.apply_layer(layer); + } + if let Some(layer) = state.default_layers.get(entity_type).cloned() { + effective.apply_layer(layer); + } + for tag in &tags { + if let Some(layer) = state.tag_layers.get(tag).cloned() { + effective.apply_layer(layer); + } + } + drop(state); + + if let Some(block) = route_apl { + let source = format!("routes.{}.apl", route_key); + let route_layer = compile_policy_block_value(&source, block) + .map_err(|e| Box::new(e) as VisitorError)?; + effective.apply_layer(route_layer); + } + + // No layers contributed anything? Don't install a handler — the + // route falls back to cpex-core's plugin-chain execution. + if effective.declared_phases().is_empty() { + continue; + } + + // E3.1 — plugin-mode validation for `parallel:` blocks. + // `apl-core::Effect::validate_parallel_purity` already rejected + // FieldOp / Delegate at parse time; this pass checks that every + // `plugin(X)` inside a `parallel:` references a plugin whose + // mode is safe for concurrent execution (Audit / Concurrent / + // FireAndForget). Sequential / Transform plugins would silently + // lose their mutations inside cloned branches. + // + // Looks up modes through the cpex-core PluginManager (it has + // the authoritative registration state). The lookup trait + // is `parallel_safety::PluginModeLookup`, which + // `PluginManager` implements. + if let Err(msg) = crate::parallel_safety::validate_parallel_plugin_modes( + &effective, + mgr.as_ref(), + ) { + let err_msg = format!("route '{}': parallel-safety: {}", route_key, msg); + return Err(err_msg.into()); + } + + let route_arc = Arc::new(effective); + + // Resolve the entity-specific CMF hook pair. The visitor's + // entity_identity() already filtered out unknown types, but + // hook_pair_for_entity returning None would just skip the + // annotation rather than crash — defense in depth. + let (hook_pre, hook_post) = match hook_pair_for_entity(entity_type) { + Some(pair) => pair, + None => { + tracing::warn!( + entity_type, + entity_name, + "APL visitor: no CMF hook pair for entity_type — skipping route", + ); + continue; + } + }; + + // Install Pre + Post handlers. Each handler instance is bound to + // ONE phase so the executor can pick the right entry-point off + // the (entity_type, entity_name, scope, hook_name) key. + install_handler( + mgr, + entity_type, + entity_name, + scope.clone(), + hook_pre, + Phase::Pre, + Arc::clone(&route_arc), + &plugin_registry, + &self.dispatch_cache, + &self.session_store, + &self.manager, + Some(Arc::clone(&pdp_router_arc)), + &self.base_capabilities, + ); + install_handler( + mgr, + entity_type, + entity_name, + scope.clone(), + hook_post, + Phase::Post, + route_arc, + &plugin_registry, + &self.dispatch_cache, + &self.session_store, + &self.manager, + Some(Arc::clone(&pdp_router_arc)), + &self.base_capabilities, + ); + } + + Ok(()) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +#[allow(clippy::too_many_arguments)] +fn install_handler( + mgr: &Arc, + entity_type: &str, + entity_name: &str, + scope: Option, + hook_name: &str, + phase: Phase, + route: Arc, + plugin_registry: &Arc, + dispatch_cache: &Arc, + session_store: &Arc, + manager: &Weak, + pdp: Option>, + base_capabilities: &std::collections::HashSet, +) { + // Capability gating at the synthetic-handler boundary. cpex-core's + // executor calls `filter_extensions(&ext, &caps)` before every + // handler invoke — including this one. If the synthetic handler + // has fewer capabilities than its downstream plugins need, the + // executor strips extensions on the way in (so APL predicates and + // downstream plugins see empty views) and rejects mutations on the + // way out (label / delegation appends fail monotonicity checks). + // + // Granted caps = union of every plugin's caps (with per-route + // overrides applied) ∪ host-supplied baseline. The baseline + // typically covers read-only attributes APL predicates touch + // (`subject.*`, `role.*`, `delegated`, …) even when no plugins are + // referenced. + let mut capabilities = base_capabilities.clone(); + capabilities.extend(crate::dispatch_plan::route_capability_union(&route, plugin_registry)); + + let plugin_config = PluginConfig { + name: format!( + "apl::{}::{}::{}", + entity_type, + entity_name, + if phase == Phase::Pre { "pre" } else { "post" } + ), + kind: "builtin".to_string(), + // The annotated handler covers exactly one CMF hook name. + hooks: vec![hook_name.to_string()], + capabilities, + ..Default::default() + }; + let mut handler = + AplRouteHandler::new( + plugin_config.clone(), + route, + phase, + Arc::clone(plugin_registry), + Arc::clone(dispatch_cache), + Arc::clone(session_store), + manager.clone(), + ); + if let Some(pdp) = pdp { + handler = handler.with_pdp(pdp); + } + mgr.annotate_route( + entity_type.to_string(), + entity_name.to_string(), + scope, + hook_name.to_string(), + Arc::new(handler), + plugin_config, + ); +} + +/// Pick the route's entity identities from the first non-None match +/// field. v0: tool > resource > prompt > llm precedence. A list-form +/// match (`tool: [a, b]`) yields one annotation per element so each +/// request gets routed by its specific name. +fn entity_identity(route: &RouteEntry) -> Option<(&'static str, Vec)> { + if let Some(t) = &route.tool { + return Some(("tool", names_of(t))); + } + if let Some(r) = &route.resource { + return Some(("resource", names_of(r))); + } + if let Some(p) = &route.prompt { + return Some(("prompt", names_of(p))); + } + if let Some(l) = &route.llm { + return Some(("llm", names_of(l))); + } + None +} + +fn names_of(sol: &cpex_core::config::StringOrList) -> Vec { + match sol { + cpex_core::config::StringOrList::Single(p) => vec![p.as_str().to_string()], + cpex_core::config::StringOrList::List(v) => v.clone(), + } +} + +/// Strip the `pdp` sub-key from an `apl:` mapping so the remainder can +/// be handed to `compile_policy_block_value` (which doesn't model PDP +/// declarations — those are CPEX wiring concerns). Returns a clone of +/// the mapping with `pdp` removed; the original is left intact. +fn strip_pdp_key(apl_block: &serde_yaml::Value) -> serde_yaml::Value { + let Some(map) = apl_block.as_mapping() else { + return apl_block.clone(); + }; + let mut cloned = map.clone(); + cloned.remove(&serde_yaml::Value::String("pdp".to_string())); + serde_yaml::Value::Mapping(cloned) +} + +/// Bridge cpex-core's JSON-based `Option` config slot +/// into apl-core's `Option` shape. JSON is a strict +/// subset of YAML's value model so this is round-trip safe; failure +/// here would only happen if `serde_yaml::to_value` rejects a value +/// `serde_json::Value` already accepted (in practice: never). +fn plugin_config_to_yaml(cfg: &Option) -> Option { + cfg.as_ref().and_then(|v| serde_yaml::to_value(v).ok()) +} + +/// Map cpex-core's `OnError` enum onto the string shape apl-core's +/// `PluginDeclaration` carries (kept stringly-typed there because the +/// APL spec also allows custom orchestrator-defined error modes). +fn on_error_to_string(on_err: &cpex_core::plugin::OnError) -> String { + on_err.to_string() +} + +/// Pull the `apl:` sub-block out of a section's raw YAML. Returns `None` +/// when absent or null — callers treat that as "no contribution from +/// this section" and move on. +fn apl_subblock(yaml: &serde_yaml::Value) -> Option<&serde_yaml::Value> { + let block = yaml.get("apl")?; + if block.is_null() { + None + } else { + Some(block) + } +} diff --git a/crates/apl-cpex/tests/capability_gating.rs b/crates/apl-cpex/tests/capability_gating.rs new file mode 100644 index 00000000..d03656e2 --- /dev/null +++ b/crates/apl-cpex/tests/capability_gating.rs @@ -0,0 +1,446 @@ +// Location: ./crates/apl-cpex/tests/capability_gating.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Capability-gating end-to-end. cpex-core's executor calls +// `filter_extensions(&ext, &caps)` before every handler invoke — so the +// synthetic `AplRouteHandler` must declare a capability set wide enough +// to cover every downstream plugin it dispatches, otherwise: +// +// - APL predicates read from a stripped attribute bag (silently wrong +// policy decisions). +// - Downstream plugins receive a doubly-filtered view (their own caps +// applied on top of an already-stripped one). +// - Write attempts (append_labels, append_delegation, write_headers) +// fail the monotonicity check on the way back out of the handler. +// +// These tests verify the visitor computes +// `base_capabilities ∪ per-route plugin union` and sets it on the +// synthetic `PluginConfig`. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError as CoreError; +use cpex_core::extensions::{MetaExtension, SecurityExtension}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; + +// ===================================================================== +// Fixtures +// ===================================================================== + +/// Plugin that records whether it saw `security.labels` populated. +/// Used to verify that `read_labels` capability propagates through the +/// synthetic handler so the inner plugin's filtered view actually +/// contains labels. +struct LabelReader { + cfg: PluginConfig, + observed_labels: Arc>>, +} + +#[async_trait] +impl Plugin for LabelReader { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for LabelReader { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let seen: Vec = extensions + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default(); + *self.observed_labels.lock().unwrap() = seen; + PluginResult::allow() + } +} + +struct LabelReaderFactory { + observed_labels: Arc>>, +} + +impl PluginFactory for LabelReaderFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(LabelReader { + cfg: config.clone(), + observed_labels: Arc::clone(&self.observed_labels), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +/// Plugin that appends a label via `modify_extensions`. Used to verify +/// write-cap propagation: requires both an `append_labels` declaration +/// on the plugin AND the synthetic handler to also be granted +/// `append_labels` so the executor accepts the mutation on the way +/// back out. +struct LabelWriter { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for LabelWriter { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for LabelWriter { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let mut owned = extensions.cow_copy(); + let security = owned.security.get_or_insert_with(Default::default); + security.add_label("APPENDED"); + PluginResult::modify_extensions(owned) + } +} + +struct LabelWriterFactory; +impl PluginFactory for LabelWriterFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(LabelWriter { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn cmf_payload(text: &str) -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, text), + } +} + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".to_string()); + meta.entity_name = Some(name.to_string()); + meta +} + +fn extensions_with_label(label: &str) -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label(label.to_string()); + Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + security: Some(Arc::new(security)), + ..Default::default() + } +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Plugin declares `read_labels`; route references it; pre-existing +/// label `EXISTING` is set on the request extensions. The plugin must +/// observe the label — proving the synthetic `AplRouteHandler` got +/// `read_labels` from the per-route plugin union (cpex-core's filter +/// would otherwise strip security.labels at the handler boundary). +#[tokio::test] +async fn plugin_with_read_labels_sees_labels_through_apl_handler() { + const YAML: &str = r#" +plugins: + - name: label-reader + kind: label-reader + hooks: [cmf.tool_pre_invoke] + capabilities: [read_labels] +routes: + - tool: get_weather + apl: + policy: + - "plugin(label-reader)" +"#; + + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "label-reader", + Box::new(LabelReaderFactory { + observed_labels: Arc::clone(&observed), + }), + ); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: None, + }, + ); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + let ext = extensions_with_label("EXISTING"); + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!( + result.continue_processing, + "plugin shouldn't deny: {:?}", + result.violation + ); + + let seen = observed.lock().unwrap().clone(); + assert_eq!( + seen, + vec!["EXISTING".to_string()], + "plugin must observe the EXISTING label that the request carried; \ + empty means the synthetic AplRouteHandler stripped security.labels \ + because its cap union didn't include read_labels" + ); +} + +/// Same plugin shape, but DON'T declare `read_labels` on the plugin +/// and set an empty `base_capabilities` so neither the per-route +/// union nor the baseline grants the cap. The plugin must NOT see +/// labels — confirms the negative case (capability gating actually +/// hides things when caps are missing). +#[tokio::test] +async fn plugin_without_read_labels_sees_stripped_view() { + const YAML: &str = r#" +plugins: + - name: label-reader + kind: label-reader + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(label-reader)" +"#; + + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "label-reader", + Box::new(LabelReaderFactory { + observed_labels: Arc::clone(&observed), + }), + ); + // Strict mode: empty baseline → only per-plugin caps grant + // anything, and the plugin declared none. + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: Some(std::collections::HashSet::new()), + }, + ); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + let ext = extensions_with_label("EXISTING"); + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!(result.continue_processing); + + let seen = observed.lock().unwrap().clone(); + assert!( + seen.is_empty(), + "plugin should see no labels when neither it nor the baseline \ + grants read_labels — got: {:?}", + seen + ); +} + +/// Plugin declares `append_labels` and emits a new label via +/// `modify_extensions`. The synthetic `AplRouteHandler` must also be +/// granted `append_labels` (from the per-route union) so its outer +/// modify_extensions write doesn't get rejected on the way back out. +/// After the invoke, the appended label must be visible in the final +/// extensions. +#[tokio::test] +async fn write_capabilities_propagate_through_apl_handler() { + const YAML: &str = r#" +plugins: + - name: label-writer + kind: label-writer + hooks: [cmf.tool_pre_invoke] + capabilities: [append_labels, read_labels] +routes: + - tool: get_weather + apl: + policy: + - "plugin(label-writer)" +"#; + + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory("label-writer", Box::new(LabelWriterFactory)); + register_apl(&mgr, AplOptions::in_process()); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!( + result.continue_processing, + "label-writer should allow: {:?}", + result.violation + ); + + // The appended label should be visible on the way out via + // `modified_extensions` — None means no plugin wrote anything, + // which would be a failure here. + let modified = result + .modified_extensions + .expect("label-writer should have modified extensions"); + let labels: Vec = modified + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default(); + assert!( + labels.contains(&"APPENDED".to_string()), + "expected APPENDED to land in final security.labels — \ + a missing label means the executor rejected the write on the \ + way out of AplRouteHandler (no append_labels cap on the synthetic). \ + Got: {:?}", + labels + ); +} + +/// Predicate-only route: no plugins, just `require(authenticated)`. +/// APL evaluates this against the attribute bag built from the +/// (capability-filtered) Extensions view the handler sees. Default +/// baseline grants `read_subject`, so `authenticated` evaluates to +/// `true` when subject is present. +#[tokio::test] +async fn predicate_only_route_uses_baseline_capabilities() { + const YAML: &str = r#" +plugins: [] +routes: + - tool: get_weather + apl: + policy: + - "require(authenticated)" +"#; + let mgr = Arc::new(PluginManager::default()); + register_apl(&mgr, AplOptions::in_process()); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + // Set subject id so `authenticated` derives true via apl-cmf. + let mut security = SecurityExtension::default(); + security.subject = Some(cpex_core::extensions::SubjectExtension { + id: Some("alice".to_string()), + ..Default::default() + }); + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + security: Some(Arc::new(security)), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!( + result.continue_processing, + "require(authenticated) should pass with subject.id set: violation = {:?}", + result.violation + ); +} + +/// Same predicate-only route but baseline is forcibly empty AND no +/// subject is set. With empty baseline the synthetic handler has no +/// caps, so security.subject is stripped → `authenticated` evaluates +/// false → `require(authenticated)` denies. Confirms the baseline +/// actually controls what predicates can read. +#[tokio::test] +async fn empty_baseline_strips_predicate_view() { + const YAML: &str = r#" +plugins: [] +routes: + - tool: get_weather + apl: + policy: + - "require(authenticated)" +"#; + let mgr = Arc::new(PluginManager::default()); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: Some(std::collections::HashSet::new()), + }, + ); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + // Even though subject.id IS set, the empty baseline means the + // synthetic handler can't read subject — predicate sees missing → + // false → require denies. + let mut security = SecurityExtension::default(); + security.subject = Some(cpex_core::extensions::SubjectExtension { + id: Some("alice".to_string()), + ..Default::default() + }); + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + security: Some(Arc::new(security)), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + assert!( + !result.continue_processing, + "empty baseline should cause require(authenticated) to deny \ + even with subject set — capability gating proves it can't see" + ); +} diff --git a/crates/apl-cpex/tests/cmf_invoker_dispatch.rs b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs new file mode 100644 index 00000000..c95788b3 --- /dev/null +++ b/crates/apl-cpex/tests/cmf_invoker_dispatch.rs @@ -0,0 +1,699 @@ +// Location: ./crates/apl-cpex/tests/cmf_invoker_dispatch.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Integration tests for `CmfPluginInvoker` — exercises the typed +// dispatch path end-to-end against a real `cpex-core::PluginManager` +// with hand-rolled test plugins. v0 coverage: +// - `Step` invocation against an allow-plugin → `Decision::Allow` +// - `Step` invocation against a deny-plugin → `Decision::Deny` with +// reason + rule_source pulled from the CPEX `PluginViolation` +// - `Field` invocation against a modify-plugin → `Decision::Allow` +// with `modified_value` populated from the rewritten text content +// - Payload mutation persists across invocations (one modifying +// plugin's output is visible to the next). + +use std::sync::Arc; + +use async_trait::async_trait; +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload}; +use cpex_core::cmf::enums::Role; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError as CoreError, PluginViolation}; +use cpex_core::extensions::{SecurityExtension, SubjectExtension}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::{HookEntry, PluginRef}; + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::step::{PluginInvocation, PluginInvoker}; + +use apl_cpex::{CmfPluginInvoker, MemorySessionStore, RouteDispatchPlan}; + +/// Build a single-plugin RouteDispatchPlan straight off the cpex-core +/// registry — no APL CompiledRoute involved. Used by the invoker-primitive +/// tests below to exercise the plan-based dispatch path without standing +/// up a full route. +fn plan_for(manager: &cpex_core::manager::PluginManager, plugin_name: &str) -> Arc { + let entry = RouteDispatchPlan::resolve_plugin(manager, plugin_name) + .expect("plugin must be registered with the manager"); + let mut plugins = std::collections::HashMap::new(); + plugins.insert(plugin_name.to_string(), entry); + Arc::new(RouteDispatchPlan { plugins, delegation_entries: Default::default() }) +} + +// --------------------------------------------------------------------- +// Test plugins — minimal CMF handlers with hard-coded behavior so the +// dispatch path is exercised without external state. +// --------------------------------------------------------------------- + +struct AllowPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +struct AllowPluginFactory; +impl PluginFactory for AllowPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +struct DenyPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "policy.forbidden", + "test-fixture denied this call", + )) + } +} + +struct DenyPluginFactory; +impl PluginFactory for DenyPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +/// Modify plugin — rewrites every Text part by appending `" [MODIFIED]"` +/// so the test can assert mutation propagation deterministically. +struct ModifyPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for ModifyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for ModifyPlugin { + async fn handle( + &self, + payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let new_content: Vec = payload + .message + .content + .iter() + .map(|part| match part { + ContentPart::Text { text } => ContentPart::Text { + text: format!("{} [MODIFIED]", text), + }, + other => other.clone(), + }) + .collect(); + PluginResult::modify_payload(MessagePayload { + message: Message { + schema_version: payload.message.schema_version.clone(), + role: payload.message.role, + content: new_content, + channel: payload.message.channel, + }, + }) + } +} + +struct ModifyPluginFactory; +impl PluginFactory for ModifyPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(ModifyPlugin { cfg: config.clone() }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.field_redact", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------- + +fn payload_with_text(text: &str) -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, text), + } +} + +fn empty_bag() -> AttributeBag { + AttributeBag::new() +} + +/// Build a manager, register one factory + one plugin under the given +/// kind, and return the wired manager ready for invocation. +async fn build_manager( + factory_kind: &str, + factory: Box, +) -> Arc { + let mgr = PluginManager::default(); + mgr.register_factory(factory_kind, factory); + + let yaml = format!( + "plugins:\n - name: {0}\n kind: {0}\n", + factory_kind + ); + let cfg = cpex_core::config::parse_config(&yaml).expect("parse_config"); + mgr.load_config(cfg).expect("load_config"); + mgr.initialize().await.expect("initialize"); + Arc::new(mgr) +} + +// --------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------- + +#[tokio::test] +async fn step_invocation_allow_returns_decision_allow() { + let mgr = build_manager("allow-plugin", Box::new(AllowPluginFactory)).await; + let plan = plan_for(&mgr, "allow-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let outcome = invoker + .invoke("allow-plugin", &empty_bag(), PluginInvocation::Step { phase: apl_core::step::DispatchPhase::Pre }) + .await + .expect("invoke"); + + assert_eq!(outcome.decision, Decision::Allow); + assert!(outcome.modified_value.is_none()); +} + +#[tokio::test] +async fn step_invocation_deny_surfaces_violation_reason_and_code() { + let mgr = build_manager("deny-plugin", Box::new(DenyPluginFactory)).await; + let plan = plan_for(&mgr, "deny-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let outcome = invoker + .invoke("deny-plugin", &empty_bag(), PluginInvocation::Step { phase: apl_core::step::DispatchPhase::Pre }) + .await + .expect("invoke"); + + match outcome.decision { + Decision::Deny { reason, rule_source } => { + assert_eq!(reason.as_deref(), Some("test-fixture denied this call")); + assert_eq!(rule_source, "policy.forbidden"); + } + other => panic!("expected Decision::Deny, got {:?}", other), + } +} + +#[tokio::test] +async fn field_invocation_modify_surfaces_modified_value_and_persists_payload() { + let mgr = build_manager("modify-plugin", Box::new(ModifyPluginFactory)).await; + let plan = plan_for(&mgr, "modify-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let bag = empty_bag(); + let value = serde_json::Value::String("hello".to_string()); + let outcome = invoker + .invoke( + "modify-plugin", + &bag, + PluginInvocation::Field { + name: "content", + value: &value, + phase: apl_core::step::DispatchPhase::Pre, + }, + ) + .await + .expect("invoke"); + + assert_eq!(outcome.decision, Decision::Allow); + assert_eq!( + outcome.modified_value, + Some(serde_json::Value::String("hello [MODIFIED]".to_string())) + ); + + // Payload mutation persisted: a second invocation sees the updated + // text as input (modifier appends [MODIFIED] each pass). + let outcome2 = invoker + .invoke( + "modify-plugin", + &bag, + PluginInvocation::Field { + name: "content", + value: &value, + phase: apl_core::step::DispatchPhase::Pre, + }, + ) + .await + .expect("invoke"); + assert_eq!( + outcome2.modified_value, + Some(serde_json::Value::String( + "hello [MODIFIED] [MODIFIED]".to_string() + )) + ); +} + +#[tokio::test] +async fn current_payload_reflects_accumulated_mutations() { + let mgr = build_manager("modify-plugin", Box::new(ModifyPluginFactory)).await; + let plan = plan_for(&mgr, "modify-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let bag = empty_bag(); + let value = serde_json::Value::String("ignored".to_string()); + let _ = invoker + .invoke( + "modify-plugin", + &bag, + PluginInvocation::Field { + name: "content", + value: &value, + phase: apl_core::step::DispatchPhase::Pre, + }, + ) + .await + .expect("invoke"); + + let final_payload = invoker.current_payload().await; + assert_eq!( + final_payload.message.get_text_content(), + "hello [MODIFIED]" + ); +} + +// --------------------------------------------------------------------- +// Capability gating — APL route override of `capabilities:` materializes +// a derived PluginRef wrapping the same plugin Arc with a merged +// TrustedConfig. cpex-core's executor then enforces the narrower caps +// in its single per-entry `filter_extensions` pass — no double filter, +// no second clone of security. The base plugin's circuit breaker stays +// isolated per `feedback_override_isolation.md`. +// --------------------------------------------------------------------- + +/// Capture-plugin fixture — records the Extensions it actually receives +/// from the executor so the test can assert what survived filtering. +struct CapturePlugin { + cfg: PluginConfig, + captured: Arc>>, +} + +#[async_trait] +impl Plugin for CapturePlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for CapturePlugin { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + *self.captured.lock().await = Some(extensions.clone()); + PluginResult::allow() + } +} + +struct CapturePluginFactory { + slot: Arc>>, +} + +impl PluginFactory for CapturePluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(CapturePlugin { + cfg: config.clone(), + captured: self.slot.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +/// Build a manager whose registered plugin holds the given capability +/// set (wide caps in this test — the override is supposed to narrow +/// what these caps would have allowed). +async fn build_manager_with_caps( + factory_kind: &str, + factory: Box, + cpex_caps: &[&str], +) -> Arc { + let mgr = PluginManager::default(); + mgr.register_factory(factory_kind, factory); + let caps_yaml = if cpex_caps.is_empty() { + String::new() + } else { + format!(" capabilities: [{}]\n", cpex_caps.join(", ")) + }; + let yaml = format!( + "plugins:\n - name: {0}\n kind: {0}\n{1}", + factory_kind, caps_yaml, + ); + let cfg = cpex_core::config::parse_config(&yaml).expect("parse_config"); + mgr.load_config(cfg).expect("load_config"); + mgr.initialize().await.expect("initialize"); + Arc::new(mgr) +} + +fn extensions_with_subject_and_labels() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.subject = Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }); + Extensions { + security: Some(Arc::new(security)), + ..Default::default() + } +} + +/// Build a RoutePluginEntry that wraps the base plugin's handler with a +/// derived PluginRef carrying narrower caps — same plugin Arc, fresh +/// circuit breaker, smaller cap set. Mirrors what +/// `RouteDispatchPlan::build` does when APL declares a route-level +/// `plugins..capabilities:` override. +fn plan_with_narrowed_caps( + manager: &PluginManager, + plugin_name: &str, + narrowed_caps: &[&str], +) -> Arc { + let base = manager + .find_plugin_entries(plugin_name) + .into_iter() + .next() + .expect("plugin registered"); + let (_hook_name, base_entry) = base; + let mut merged = base_entry.plugin_ref.trusted_config().clone(); + merged.capabilities = narrowed_caps.iter().map(|s| s.to_string()).collect(); + let override_ref = Arc::new(PluginRef::new( + Arc::clone(base_entry.plugin_ref.plugin()), + merged, + )); + let entry = HookEntry { + plugin_ref: override_ref, + handler: Arc::clone(&base_entry.handler), + }; + let mut plugins = std::collections::HashMap::new(); + let mut entries_by_hook = std::collections::HashMap::new(); + entries_by_hook.insert("cmf.tool_pre_invoke".to_string(), entry); + plugins.insert( + plugin_name.to_string(), + apl_cpex::RoutePluginEntry { + plugin_name: plugin_name.to_string(), + entries_by_hook, + }, + ); + Arc::new(apl_cpex::RouteDispatchPlan { plugins, delegation_entries: Default::default() }) +} + +#[tokio::test] +async fn route_override_caps_narrow_what_plugin_sees() { + // cpex-core registers the plugin with WIDE caps: read_subject AND + // read_labels. Without an override, the plugin would see both. + let captured = Arc::new(tokio::sync::Mutex::new(None)); + let factory = CapturePluginFactory { + slot: captured.clone(), + }; + let mgr = build_manager_with_caps( + "capture-plugin", + Box::new(factory), + &["read_subject", "read_labels"], + ) + .await; + + // APL route override narrows to ONLY read_subject — labels should + // be stripped despite cpex-core having registered them. + let plan = plan_with_narrowed_caps(&mgr, "capture-plugin", &["read_subject"]); + + let invoker = CmfPluginInvoker::for_request( + mgr, + extensions_with_subject_and_labels(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + let outcome = invoker + .invoke("capture-plugin", &empty_bag(), PluginInvocation::Step { phase: apl_core::step::DispatchPhase::Pre }) + .await + .expect("invoke"); + assert_eq!(outcome.decision, Decision::Allow); + + let captured = captured.lock().await.clone().expect("handler ran"); + let security = captured.security.expect("security extension present"); + + // read_subject is in the narrowed set → subject still visible. + assert!( + security.subject.is_some(), + "route override declared read_subject; plugin should see subject" + ); + assert_eq!( + security.subject.as_ref().unwrap().id.as_deref(), + Some("alice") + ); + + // read_labels is NOT in the narrowed set → labels stripped, even + // though cpex-core's registration would have allowed them through. + assert!( + security.labels.is_empty(), + "route override dropped read_labels; labels should be empty (got {:?})", + security.labels, + ); +} + +// --------------------------------------------------------------------- +// Slice 101 — hook routing table regression +// --------------------------------------------------------------------- +// +// Multi-hook plugin selection bug regression: a plugin registered +// under BOTH `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` must +// dispatch to the right entry per phase. Before Slice 101 the +// dispatch plan classified both as "step" and arbitrary "first +// non-field wins" picked one for every dispatch — silent wrong +// routing when policy and post_policy needed different handlers. + +/// Pre-side handler — returns Allow with no modification. +struct PreSideHandler { + cfg: PluginConfig, +} +#[async_trait] +impl Plugin for PreSideHandler { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} +impl HookHandler for PreSideHandler { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +/// Post-side handler — returns Deny with a distinctive violation +/// code so the test can assert "which handler fired" from the +/// outcome alone. +struct PostSideHandler { + cfg: PluginConfig, +} +#[async_trait] +impl Plugin for PostSideHandler { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} +impl HookHandler for PostSideHandler { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(cpex_core::error::PluginViolation::new( + "test.multi_hook.post_fired", + "post handler fired", + )) + } +} + +/// Marker plugin held by the PluginInstance (handlers are +/// independent structs — the marker satisfies the +/// `PluginInstance.plugin` field). +struct MultiHookMarker { + cfg: PluginConfig, +} +#[async_trait] +impl Plugin for MultiHookMarker { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +struct MultiHookPluginFactory; +impl PluginFactory for MultiHookPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let marker = Arc::new(MultiHookMarker { cfg: config.clone() }); + let pre = Arc::new(PreSideHandler { cfg: config.clone() }); + let post = Arc::new(PostSideHandler { cfg: config.clone() }); + Ok(PluginInstance { + plugin: marker as Arc, + handlers: vec![ + ( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(pre)), + ), + ( + "cmf.tool_post_invoke", + Arc::new(TypedHandlerAdapter::::new(post)), + ), + ], + }) + } +} + +/// Plugin registered under both `cmf.tool_pre_invoke` and +/// `cmf.tool_post_invoke`. `PluginInvocation::Step { phase: Pre }` +/// must pick the pre-side handler; `Step { phase: Post }` must pick +/// the post-side handler. The post handler emits a distinctive +/// violation code so we can prove WHICH handler fired from the +/// outcome alone — not just that "a handler" fired. +#[tokio::test] +async fn multi_hook_plugin_dispatches_per_phase_via_routing_table() { + let mgr = build_manager("multi-hook-plugin", Box::new(MultiHookPluginFactory)).await; + let plan = plan_for(&mgr, "multi-hook-plugin"); + let invoker = CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + payload_with_text("hello"), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await; + + // Pre phase — should hit pre handler → Allow. + let pre_outcome = invoker + .invoke( + "multi-hook-plugin", + &empty_bag(), + PluginInvocation::Step { + phase: apl_core::step::DispatchPhase::Pre, + }, + ) + .await + .expect("pre invoke"); + assert_eq!(pre_outcome.decision, Decision::Allow); + + // Post phase — should hit post handler → Deny with the + // distinctive code. Proves the post handler ran, not the pre + // handler (which would have returned Allow). + let post_outcome = invoker + .invoke( + "multi-hook-plugin", + &empty_bag(), + PluginInvocation::Step { + phase: apl_core::step::DispatchPhase::Post, + }, + ) + .await + .expect("post invoke"); + match post_outcome.decision { + Decision::Deny { rule_source, .. } => { + assert_eq!( + rule_source, "test.multi_hook.post_fired", + "Post phase should dispatch to the post-side handler", + ); + } + d => panic!("expected Deny from post handler, got {d:?}"), + } +} diff --git a/crates/apl-cpex/tests/config_override.rs b/crates/apl-cpex/tests/config_override.rs new file mode 100644 index 00000000..3bfb705d --- /dev/null +++ b/crates/apl-cpex/tests/config_override.rs @@ -0,0 +1,519 @@ +// Location: ./crates/apl-cpex/tests/config_override.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Route-level `config:` override propagation. The unified-config spec +// allows a route to declare `plugins..config: { ... }` that +// REPLACES (not merges) the plugin's base config for THIS route only. +// +// Under the hood: +// +// 1. `AplConfigVisitor` parses the override into `CompiledRoute.plugin_overrides`. +// 2. `RouteDispatchPlan::build` calls `manager.build_override_entries(name, config, caps, on_error)`. +// 3. cpex-core's `build_override_entries` invokes the plugin factory +// with the merged `PluginConfig`, calls `initialize()` on the +// result, and wraps every returned handler in a fresh `PluginRef` +// with an independent circuit breaker. +// +// These tests prove the value the route declared actually reaches the +// plugin's `Plugin::config()` (factory was called with the override) +// and that the base instance is unaffected when a *separate* route +// uses the base config. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError as CoreError, PluginViolation}; +use cpex_core::extensions::MetaExtension; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; + +// ===================================================================== +// Fixtures +// ===================================================================== + +/// Plugin that reads its OWN `config.allowlist` (a list of strings) and +/// denies the request unless `"open"` is in the list. The point is that +/// each instance (base vs override) reads from its own +/// `Plugin::config()` — which is set at factory-construction time. +/// If the route override never reaches the factory, the override +/// instance has the base config and the gate behaves the same as base. +struct AllowlistGate { + cfg: PluginConfig, +} + +impl AllowlistGate { + fn allowlist(&self) -> Vec { + self.cfg + .config + .as_ref() + .and_then(|v| v.get("allowlist")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + } +} + +#[async_trait] +impl Plugin for AllowlistGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowlistGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if self.allowlist().iter().any(|s| s == "open") { + PluginResult::allow() + } else { + PluginResult::deny(PluginViolation::new( + "policy.config_gate", + format!( + "allowlist does not include 'open' — saw {:?}", + self.allowlist() + ), + )) + } + } +} + +/// Counter so we can prove the factory was invoked again for the +/// override route (i.e. a *new* instance, not a shared one). Two +/// `mgr.invoke_named` calls against two different routes should +/// trigger exactly two factory calls: one at `load_config` for the +/// base, one at `build_override_entries` for the override route. The +/// dispatch cache memoizes the override entry, so subsequent invokes +/// against the same route don't re-instantiate. +struct AllowlistGateFactory { + instance_count: Arc, +} + +impl PluginFactory for AllowlistGateFactory { + fn create(&self, config: &PluginConfig) -> Result> { + self.instance_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let plugin = Arc::new(AllowlistGate { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn cmf_payload() -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, "x"), + } +} + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".to_string()); + meta.entity_name = Some(name.to_string()); + meta +} + +async fn build_manager(yaml: &str) -> (Arc, Arc) { + let instance_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "allowlist-gate", + Box::new(AllowlistGateFactory { + instance_count: Arc::clone(&instance_count), + }), + ); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: None, + }, + ); + mgr.load_config_yaml(yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + (mgr, instance_count) +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Base config: `allowlist: ["closed"]` → plugin denies. Route +/// `tool_a` doesn't override, so it uses the base — should deny. +/// Route `tool_b` overrides `allowlist: ["open"]` → factory builds a +/// new instance with that config → plugin allows. Proves the override +/// reaches the factory and the new instance reads it. +#[tokio::test] +async fn config_override_replaces_base_config_for_route() { + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["closed"] +routes: + - tool: tool_a + apl: + policy: + - "plugin(gate)" + - tool: tool_b + apl: + plugins: + gate: + config: + allowlist: ["open"] + policy: + - "plugin(gate)" +"#; + let (mgr, instance_count) = build_manager(YAML).await; + + // tool_a uses the base config → denies. + let ext_a = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_a"))), + ..Default::default() + }; + let (res_a, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_a, None) + .await; + let v = res_a + .violation + .expect("base config has no 'open' — should deny"); + assert_eq!(v.code, "policy.config_gate"); + assert!( + v.reason.contains("\"closed\""), + "violation should report the base allowlist, got: {}", + v.reason + ); + + // tool_b uses the override → allows. + let ext_b = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_b"))), + ..Default::default() + }; + let (res_b, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_b, None) + .await; + assert!( + res_b.continue_processing, + "override should allow — violation: {:?}", + res_b.violation + ); + + // Factory invoked exactly twice: once at load_config for the base, + // once at build_override_entries for tool_b. tool_a doesn't override, + // so no extra call. Subsequent invokes hit the dispatch cache. + assert_eq!( + instance_count.load(std::sync::atomic::Ordering::SeqCst), + 2, + "expected one factory call for base + one for override; \ + a different count means caching / override path is wrong" + ); +} + +/// Run tool_b twice. The dispatch cache must memoize the override +/// instance built on the first call so the second call doesn't trigger +/// another factory invocation. Two routes with overrides should still +/// produce exactly 1 + N instances (base + one per overriding route), +/// regardless of how many invokes hit each route. +#[tokio::test] +async fn dispatch_cache_memoizes_override_instances() { + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["closed"] +routes: + - tool: tool_b + apl: + plugins: + gate: + config: + allowlist: ["open"] + policy: + - "plugin(gate)" +"#; + let (mgr, instance_count) = build_manager(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_b"))), + ..Default::default() + }; + + // Three invokes against tool_b. The factory should fire once for + // the base (at load_config) and once for the override (at the + // first dispatch); the second + third dispatches hit the cache. + for _ in 0..3 { + let (res, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext.clone(), None) + .await; + assert!(res.continue_processing, "{:?}", res.violation); + } + + assert_eq!( + instance_count.load(std::sync::atomic::Ordering::SeqCst), + 2, + "factory should be called exactly twice across three invokes — \ + the dispatch cache must reuse the override instance after the \ + first build" + ); +} + +/// Override only `on_error` (no `config:`). Per the spec, this should +/// take the fast path inside `build_override_entries`: shared base +/// plugin Arc, fresh `PluginRef` with merged `TrustedConfig`. The +/// factory must NOT be re-invoked. +#[tokio::test] +async fn caps_only_override_does_not_reinstantiate() { + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["open"] +routes: + - tool: tool_c + apl: + plugins: + gate: + on_error: ignore + policy: + - "plugin(gate)" +"#; + let (mgr, instance_count) = build_manager(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_c"))), + ..Default::default() + }; + let (res, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext, None) + .await; + assert!(res.continue_processing); + + // Only the base instantiation happened at load_config. The override + // only changes on_error, so the shared-base PluginRef path fires + // and no factory call is made for the route variant. + assert_eq!( + instance_count.load(std::sync::atomic::Ordering::SeqCst), + 1, + "caps/on_error-only override must NOT re-invoke the factory; \ + doing so would burn resources for a trivial config diff" + ); +} + +// --------------------------------------------------------------------- +// Extended coverage — two routes with distinct config overrides for +// the same plugin, and on_error-override plumbing verification. +// --------------------------------------------------------------------- + +/// Two routes (`tool_a`, `tool_b`) reference the same plugin (`gate`) +/// with DIFFERENT config overrides. The dispatch cache must produce +/// two independent instances, one per route, each carrying its own +/// override config — proves the cache key (`route_key`) keeps the +/// instances separate. Verified by the per-route runtime behavior +/// AND by the factory-call count: base + override_a + override_b = 3. +#[tokio::test] +async fn two_routes_with_distinct_overrides_produce_distinct_instances() { + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["closed"] +routes: + - tool: tool_a + apl: + plugins: + gate: + config: + allowlist: ["alpha"] + policy: + - "plugin(gate)" + - tool: tool_b + apl: + plugins: + gate: + config: + allowlist: ["open"] + policy: + - "plugin(gate)" +"#; + let (mgr, instance_count) = build_manager(YAML).await; + + // tool_a override: allowlist=["alpha"] — gate denies (no "open"). + let ext_a = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_a"))), + ..Default::default() + }; + let (res_a, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_a, None) + .await; + let v_a = res_a + .violation + .expect("tool_a override has no 'open' — should deny"); + assert!( + v_a.reason.contains("\"alpha\""), + "tool_a violation should report its own override allowlist (alpha), got: {}", + v_a.reason, + ); + + // tool_b override: allowlist=["open"] — gate allows. + let ext_b = Extensions { + meta: Some(Arc::new(meta_for_tool("tool_b"))), + ..Default::default() + }; + let (res_b, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload(), ext_b, None) + .await; + assert!( + res_b.continue_processing, + "tool_b override has 'open' — should allow: {:?}", + res_b.violation, + ); + + // Factory invocation count: base (at load_config) + tool_a + // override (at first tool_a dispatch) + tool_b override (at first + // tool_b dispatch). Three total — proves the cache holds two + // distinct instances rather than collapsing them. + assert_eq!( + instance_count.load(std::sync::atomic::Ordering::SeqCst), + 3, + "expected 3 factory calls (base + tool_a override + tool_b override); \ + a smaller count means overrides collapsed across routes", + ); +} + +/// Override changes `on_error` only — sanity-check that the override +/// VALUE actually lands on the per-route plugin entry's trusted_config, +/// not just that the factory wasn't re-invoked. +/// +/// Counterpart to `caps_only_override_does_not_reinstantiate` (which +/// only checks the perf optimization). This test verifies the +/// PLUMBING: build the plan with and without an on_error override, +/// then read the resolved entry's trusted_config to confirm the +/// override actually flowed through (`Ignore`) vs the base default +/// (`Fail`). +#[tokio::test] +async fn on_error_override_plumbs_through_to_trusted_config() { + use std::collections::HashMap; + + use apl_cpex::{DispatchCache, RouteDispatchPlan}; + use apl_core::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry}; + use apl_core::rules::{CompiledRoute, Effect}; + use cpex_core::plugin::OnError; + + // Single-plugin cpex-core config — load it via the manager so the + // plugin is registered. No APL visitor / routes wiring needed — + // we'll build the routes manually below to focus on what the + // dispatch plan does with overrides. + const YAML: &str = r#" +plugins: + - name: gate + kind: allowlist-gate + hooks: [cmf.tool_pre_invoke] + config: + allowlist: ["open"] +"#; + let (mgr, _) = build_manager(YAML).await; + + // Construct the APL plugin registry by hand to match what + // `compile_config` would have produced for the YAML's `plugins:` + // block. `RouteDispatchPlan::build` consults this to know which + // plugins to resolve through cpex-core. + let mut registry = PluginRegistry::new(); + registry.insert( + "gate".to_string(), + PluginDeclaration { + name: "gate".to_string(), + kind: "allowlist-gate".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + capabilities: Vec::new(), + config: None, + on_error: None, + extra: HashMap::new(), + }, + ); + + let cache = DispatchCache::new(); + + // Override route — sets `on_error: ignore` only. + let mut route_override = CompiledRoute::default(); + route_override.route_key = "override-route".into(); + route_override.policy.push(Effect::Plugin { name: "gate".into() }); + let mut override_block = PluginOverride::default(); + override_block.on_error = Some("ignore".into()); + route_override + .plugin_overrides + .insert("gate".to_string(), override_block); + let plan_override: std::sync::Arc = + cache.get_or_build(&route_override, ®istry, &mgr).await; + let entry_override = plan_override + .plugins + .get("gate") + .expect("gate must resolve on override route") + .entries_by_hook + .values() + .next() + .expect("override route entry present"); + assert_eq!( + entry_override.plugin_ref.trusted_config().on_error, + OnError::Ignore, + "override route should carry on_error=Ignore on its entry", + ); + + // Base-config route — no overrides; should carry default Fail. + let mut route_base = CompiledRoute::default(); + route_base.route_key = "base-route".into(); + route_base.policy.push(Effect::Plugin { name: "gate".into() }); + let plan_base = cache.get_or_build(&route_base, ®istry, &mgr).await; + let entry_base = plan_base + .plugins + .get("gate") + .expect("gate must resolve on base route") + .entries_by_hook + .values() + .next() + .expect("base route entry present"); + assert_eq!( + entry_base.plugin_ref.trusted_config().on_error, + OnError::Fail, + "base route should carry the default on_error=Fail", + ); +} diff --git a/crates/apl-cpex/tests/delegate_step_e2e.rs b/crates/apl-cpex/tests/delegate_step_e2e.rs new file mode 100644 index 00000000..ffd973ac --- /dev/null +++ b/crates/apl-cpex/tests/delegate_step_e2e.rs @@ -0,0 +1,913 @@ +// Location: ./crates/apl-cpex/tests/delegate_step_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for `Step::Delegate` dispatch (Slice B). +// +// Verifies the full flow: +// * APL parser produces a `Step::Delegate` from policy YAML. +// * apl-cpex's `RouteDispatchPlan::build` resolves the plugin's +// `token.delegate` entry into `plan.delegation_entries`. +// * apl-cpex's `DelegationPluginInvoker` constructs a +// `DelegationPayload`, dispatches via +// `invoke_entries::(...)`, applies the +// resulting payload to extensions, and surfaces granted_* +// attributes for downstream rules. +// * Downstream `require(delegation.granted.* ...)` predicates see +// the populated bag attributes (IdP-as-PDP path). +// * `on_error: deny` (the default) halts the route on plugin deny; +// `on_error: continue` lets the pipeline keep going. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::{Duration, Utc}; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{ + DelegationPayload, TokenDelegateHook, HOOK_TOKEN_DELEGATE, +}; +use cpex_core::error::PluginViolation; +use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole, +}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, PdpCall, PdpDecision, PdpDialect, + PdpError, PdpResolver, RoutePayload, +}; + +use apl_cpex::{ + CmfPluginInvoker, DelegationPluginInvoker, DispatchCache, MemorySessionStore, SessionStore, +}; + +// --------------------------------------------------------------------- +// Fake TokenDelegateHook plugin — records every call and produces a +// configurable response (grant scopes / deny). +// --------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct DelegateCallRecord { + plugin_name: String, + target_name: String, + target_audience: Option, + required_permissions: Vec, +} + +struct RecordingDelegate { + cfg: PluginConfig, + /// Shared ledger — tests assert on what the plugin saw. + ledger: Arc>>, + /// `Some` → mint a token with these scopes; `None` → deny with + /// the supplied violation code. + grant_scopes: Option>, + grant_audience: String, + deny_code: Option, + /// Snapshot of what extensions the plugin observed when invoked. + /// Used by capability-gating tests to verify the executor's + /// per-entry filter narrowed the view to declared caps. + observed_extensions: Arc>>, +} + +/// Compact summary of what a delegate plugin saw in `Extensions` — +/// just the slots cap-gating tests care about. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ExtensionsObservation { + saw_subject_id: Option, + saw_labels: Vec, + saw_inbound_token_for_user: bool, + saw_delegation_chain_present: bool, +} + +#[async_trait] +impl Plugin for RecordingDelegate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for RecordingDelegate { + async fn handle( + &self, + payload: &DelegationPayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + self.ledger.lock().unwrap().push(DelegateCallRecord { + plugin_name: self.cfg.name.clone(), + target_name: payload.target_name().to_string(), + target_audience: payload.target_audience().map(str::to_string), + required_permissions: payload.required_permissions().to_vec(), + }); + + // Snapshot what this plugin sees in Extensions — the executor's + // per-entry capability filter narrows the view BEFORE handing + // it to the handler, so any slot we see proves the cap that + // gates it was declared. + *self.observed_extensions.lock().unwrap() = Some(ExtensionsObservation { + saw_subject_id: ext + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.clone()), + saw_labels: ext + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default(), + saw_inbound_token_for_user: ext + .raw_credentials + .as_ref() + .map(|rc| rc.inbound_tokens.contains_key(&TokenRole::User)) + .unwrap_or(false), + saw_delegation_chain_present: ext.delegation.is_some(), + }); + + if let Some(code) = &self.deny_code { + return PluginResult::deny(PluginViolation::new( + code.clone(), + format!("recording-delegate `{}` denied", self.cfg.name), + )); + } + + // Grant case — mint a fake token. + let scopes = self.grant_scopes.clone().unwrap_or_default(); + let token = RawDelegatedToken::new( + format!("fake.token.for.{}", self.cfg.name), + "Authorization", + self.grant_audience.clone(), + scopes, + Utc::now() + Duration::seconds(300), + ); + let mut updated = payload.clone(); + updated.delegated_token = Some(token); + PluginResult::modify_payload(updated) + } +} + +fn delegate_cfg(name: &str) -> PluginConfig { + delegate_cfg_with_caps(name, &[]) +} + +/// Same as `delegate_cfg` but with declared capabilities. Capability +/// names map to cpex-core's `filter_extensions` rules — e.g. +/// `read_subject`, `read_labels`, `read_inbound_credentials`, +/// `read_delegation`. Used by cap-gating tests. +fn delegate_cfg_with_caps(name: &str, caps: &[&str]) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec![HOOK_TOKEN_DELEGATE.to_string()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: caps.iter().map(|s| s.to_string()).collect(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } +} + +// --------------------------------------------------------------------- +// Stub PDP — apl-core's evaluator requires `&dyn PdpResolver`; no +// scenario here exercises a PDP step, so an always-allow stub is +// enough. +// --------------------------------------------------------------------- + +struct AllowPdp; +#[async_trait] +impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { + decision: Decision::Allow, + diagnostics: vec![], + }) + } +} + +// --------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------- + +/// Build request-level Extensions with a fake inbound bearer token so +/// `DelegationPluginInvoker` has something to put in the +/// DelegationPayload's bearer slot. +fn ext_with_bearer(token: &str) -> Extensions { + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new(token, "Authorization", TokenKind::Jwt), + ); + Extensions { + raw_credentials: Some(Arc::new(raw)), + ..Default::default() + } +} + +/// Build Extensions populated with a subject + label so cap-gating +/// tests can verify what a delegate plugin actually sees after the +/// executor's per-entry filter narrows the view to declared caps. +fn ext_with_subject_and_label( + token: &str, + subject_id: &str, + label: &str, +) -> Extensions { + use cpex_core::extensions::{SecurityExtension, SubjectExtension}; + + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new(token, "Authorization", TokenKind::Jwt), + ); + + let mut sec = SecurityExtension::default(); + sec.subject = Some(SubjectExtension { + id: Some(subject_id.to_string()), + ..Default::default() + }); + sec.add_label(label); + + Extensions { + raw_credentials: Some(Arc::new(raw)), + security: Some(Arc::new(sec)), + ..Default::default() + } +} + +/// Wire up a PluginManager with one or more TokenDelegate plugins, +/// run the route YAML through apl-core's compile, and return the +/// pieces a test needs to invoke a route. +async fn build_setup( + yaml: &str, + plugins: Vec<(String, Arc, PluginConfig)>, +) -> (Arc, apl_core::CompiledConfig, Arc) { + let mgr = Arc::new(PluginManager::default()); + for (_, plugin, cfg) in plugins { + mgr.register_handler::(plugin, cfg) + .expect("register delegate plugin"); + } + mgr.initialize().await.expect("initialize"); + let cfg = compile_config(yaml).expect("compile route YAML"); + let cache = Arc::new(DispatchCache::new()); + (mgr, cfg, cache) +} + +// --------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------- + +/// Baseline: a route with one `delegate(...)` step. The plugin is +/// called with the args from the step, mints a token, and the +/// resulting `delegation.granted.*` bag attributes are visible to +/// downstream `require(...)` rules. +#[tokio::test] +async fn delegate_step_grants_visible_to_downstream_require() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let plugin = Arc::new(RecordingDelegate { + cfg: delegate_cfg("workday-oauth"), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_compensation".to_string()]), + grant_audience: "workday-api".to_string(), + deny_code: None, + observed_extensions: Arc::new(Mutex::new(None)), + }); + + // APL semantics: `allow` rules don't short-circuit — only `deny` + // halts (spec §3). So the assertion shape is "deny if NOT granted", + // which falls through to the implicit allow at end-of-steps when + // the delegate succeeded. + let yaml = r#" +plugins: + - name: workday-oauth + kind: test + hooks: [token.delegate] +routes: + get_compensation: + policy: + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "!delegation.granted: deny" + - "!(delegation.granted.permissions contains 'read_compensation'): deny" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![( + "workday-oauth".to_string(), + Arc::clone(&plugin), + delegate_cfg("workday-oauth"), + )], + ) + .await; + + let route = cfg.routes.get("get_compensation").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_bearer("eyJ.fake.user-jwt"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "fetch compensation", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + // Inspect the bag directly — this proves the evaluator wrote + // granted_* keys, giving us specific diagnostics if the route + // fails for some other reason. + assert!( + matches!( + bag.get("delegation.granted"), + Some(apl_core::attributes::AttributeValue::Bool(true)) + ), + "delegation.granted should be true; bag has: {:?}", + bag.get("delegation.granted"), + ); + let perms = bag + .get_string_set("delegation.granted.permissions") + .expect("granted.permissions present"); + assert!(perms.contains("read_compensation")); + + assert_eq!( + decision.decision, + Decision::Allow, + "route should allow; got: {:?}", + decision.decision, + ); + + // Plugin was called with the right args. + let calls = ledger.lock().unwrap().clone(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].plugin_name, "workday-oauth"); + assert_eq!(calls[0].target_name, "workday-api"); + assert_eq!(calls[0].required_permissions, vec!["read_compensation"]); + + // Extensions now carry the minted token under raw_credentials. + let final_ext = invoker.current_extensions().await; + let raw = final_ext + .raw_credentials + .as_ref() + .expect("raw_credentials present"); + assert_eq!(raw.delegated_tokens.len(), 1, "one minted token"); + let token = raw.delegated_tokens.values().next().unwrap(); + assert_eq!(token.audience, "workday-api"); + assert_eq!(token.scopes, vec!["read_compensation"]); +} + +/// IdP-as-PDP: when the plugin denies (e.g. simulating IdP refusal), +/// the route halts with the plugin's violation code — `on_error: deny` +/// is the default and translates the delegate's deny into a route +/// deny. +#[tokio::test] +async fn delegate_step_default_on_error_denies_route() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let plugin = Arc::new(RecordingDelegate { + cfg: delegate_cfg("workday-oauth"), + ledger: Arc::clone(&ledger), + grant_scopes: None, + grant_audience: String::new(), + deny_code: Some("delegation.idp_rejected".to_string()), + observed_extensions: Arc::new(Mutex::new(None)), + }); + + // Plugin denies. Default on_error: deny → route halts at the + // delegate step itself with the plugin's violation code. No + // downstream rule needed for the test. + let yaml = r#" +plugins: + - name: workday-oauth + kind: test + hooks: [token.delegate] +routes: + get_compensation: + policy: + - "delegate(workday-oauth, target: workday-api, permissions: [write_everything])" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![( + "workday-oauth".to_string(), + Arc::clone(&plugin), + delegate_cfg("workday-oauth"), + )], + ) + .await; + + let route = cfg.routes.get("get_compensation").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_bearer("eyJ.fake.user-jwt"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "fetch comp", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + match decision.decision { + Decision::Deny { rule_source, .. } => { + assert_eq!( + rule_source, "delegation.idp_rejected", + "rule_source should carry the plugin's violation code", + ); + } + d => panic!("expected Deny on plugin deny, got {d:?}"), + } + assert_eq!( + ledger.lock().unwrap().len(), + 1, + "delegate plugin was called once", + ); +} + +/// `on_error: continue` — even on plugin deny, the route keeps +/// going. Downstream rules can branch on `delegation.granted` being +/// absent. Useful for "try delegation, fall back to a different +/// flow" patterns. +#[tokio::test] +async fn delegate_step_on_error_continue_lets_pipeline_proceed() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let plugin = Arc::new(RecordingDelegate { + cfg: delegate_cfg("audit-receipt"), + ledger: Arc::clone(&ledger), + grant_scopes: None, + grant_audience: String::new(), + deny_code: Some("audit.unavailable".to_string()), + observed_extensions: Arc::new(Mutex::new(None)), + }); + + let yaml = r#" +plugins: + - name: audit-receipt + kind: test + hooks: [token.delegate] +routes: + any: + policy: + - "delegate(audit-receipt, target: audit, on_error: continue)" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![( + "audit-receipt".to_string(), + Arc::clone(&plugin), + delegate_cfg("audit-receipt"), + )], + ) + .await; + + let route = cfg.routes.get("any").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_bearer("eyJ.fake.user-jwt"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "any", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + assert_eq!( + decision.decision, + Decision::Allow, + "on_error: continue → route allows despite plugin deny", + ); +} + +/// Most-recent-wins semantics for multiple `delegate(...)` calls in +/// one phase. Two delegates in a row both succeed; the +/// `delegation.granted.*` bag keys reflect the LAST one. +/// Extensions-side carries BOTH minted tokens (`raw_credentials.delegated_tokens`). +#[tokio::test] +async fn multiple_delegates_most_recent_wins_in_bag_extensions_accumulate() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let workday = Arc::new(RecordingDelegate { + cfg: delegate_cfg("workday-oauth"), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_compensation".to_string()]), + grant_audience: "workday-api".to_string(), + deny_code: None, + observed_extensions: Arc::new(Mutex::new(None)), + }); + let payroll = Arc::new(RecordingDelegate { + cfg: delegate_cfg("payroll-oauth"), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_salary".to_string()]), + grant_audience: "payroll-api".to_string(), + deny_code: None, + observed_extensions: Arc::new(Mutex::new(None)), + }); + + // After both delegates run, the bag reflects payroll's grants + // (most recent). The contains-check on 'read_salary' succeeds + // (because payroll's grant is what's currently in + // `delegation.granted.permissions`); a check for + // 'read_compensation' would FAIL even though workday minted a + // token with that permission, because the bag key is + // overwritten. Extensions-side accumulation (both tokens + // present) is verified separately below. + let yaml = r#" +plugins: + - name: workday-oauth + kind: test + hooks: [token.delegate] + - name: payroll-oauth + kind: test + hooks: [token.delegate] +routes: + fanout: + policy: + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "delegate(payroll-oauth, target: payroll-api, permissions: [read_salary])" + - "!(delegation.granted.permissions contains 'read_salary'): deny" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![ + ( + "workday-oauth".to_string(), + Arc::clone(&workday), + delegate_cfg("workday-oauth"), + ), + ( + "payroll-oauth".to_string(), + Arc::clone(&payroll), + delegate_cfg("payroll-oauth"), + ), + ], + ) + .await; + + let route = cfg.routes.get("fanout").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_bearer("eyJ.fake.user-jwt"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "fanout", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + assert_eq!(decision.decision, Decision::Allow); + + // Both plugins fired, in order. + let calls = ledger.lock().unwrap().clone(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].plugin_name, "workday-oauth"); + assert_eq!(calls[1].plugin_name, "payroll-oauth"); + + // Extensions accumulate — BOTH minted tokens are stashed. + let final_ext = invoker.current_extensions().await; + let raw = final_ext.raw_credentials.as_ref().unwrap(); + assert_eq!(raw.delegated_tokens.len(), 2); + let auds: std::collections::HashSet<&str> = raw + .delegated_tokens + .values() + .map(|t| t.audience.as_str()) + .collect(); + assert!(auds.contains("workday-api")); + assert!(auds.contains("payroll-api")); +} + +// --------------------------------------------------------------------- +// Capability gating on the delegate() step path. +// +// The executor calls `filter_extensions(&ext, &caps)` per entry before +// each handler runs (executor.rs:440 in cpex-core). These tests pin +// that behavior end-to-end for the `Step::Delegate` dispatch path — +// proves that what an operator declares as `capabilities:` on a +// `token.delegate` plugin is enforced exactly the same way it is for +// CMF plugins. +// --------------------------------------------------------------------- + +/// Delegate plugin declaring `read_subject` AND `read_inbound_credentials` +/// (the inbound-credentials cap is needed because the bearer token +/// arrives via raw_credentials and the invoker passes Extensions +/// through unmodified beyond the per-entry filter). Plugin sees the +/// subject, sees the inbound bearer token, but NOT the security label +/// (no read_labels cap). +#[tokio::test] +async fn delegate_with_read_subject_sees_subject_but_not_labels() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let observed: Arc>> = Arc::new(Mutex::new(None)); + + let plugin_cfg = delegate_cfg_with_caps( + "scoped-delegate", + &["read_subject", "read_inbound_credentials"], + ); + let plugin = Arc::new(RecordingDelegate { + cfg: plugin_cfg.clone(), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_compensation".to_string()]), + grant_audience: "workday-api".to_string(), + deny_code: None, + observed_extensions: Arc::clone(&observed), + }); + + let yaml = r#" +plugins: + - name: scoped-delegate + kind: test + hooks: [token.delegate] +routes: + get_compensation: + policy: + - "delegate(scoped-delegate, target: workday-api, permissions: [read_compensation])" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![("scoped-delegate".to_string(), Arc::clone(&plugin), plugin_cfg)], + ) + .await; + + let route = cfg.routes.get("get_compensation").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + // Extensions with BOTH subject (id=alice) AND a label (pii) — + // proves the cap filter is selective. + let extensions = ext_with_subject_and_label("eyJ.fake.jwt", "alice", "pii"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "fetch compensation", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let _ = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + let obs = observed + .lock() + .unwrap() + .clone() + .expect("plugin should have run and recorded its view"); + + assert_eq!( + obs.saw_subject_id.as_deref(), + Some("alice"), + "read_subject cap should expose subject.id", + ); + assert!( + obs.saw_inbound_token_for_user, + "read_inbound_credentials cap should expose the inbound user token", + ); + assert!( + obs.saw_labels.is_empty(), + "without read_labels, the label should NOT leak — saw: {:?}", + obs.saw_labels, + ); +} + +/// Delegate plugin declaring NO capabilities. Should see NOTHING in +/// security or raw_credentials — the executor strips both slots +/// because no relevant cap is held. Verifies the negative case: +/// failure to declare a cap actually does hide the slot. +#[tokio::test] +async fn delegate_without_caps_sees_stripped_extensions() { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + let observed: Arc>> = Arc::new(Mutex::new(None)); + + // Empty caps array — plugin opts into nothing. + let plugin_cfg = delegate_cfg_with_caps("capless-delegate", &[]); + let plugin = Arc::new(RecordingDelegate { + cfg: plugin_cfg.clone(), + ledger: Arc::clone(&ledger), + grant_scopes: Some(vec!["read_compensation".to_string()]), + grant_audience: "workday-api".to_string(), + deny_code: None, + observed_extensions: Arc::clone(&observed), + }); + + let yaml = r#" +plugins: + - name: capless-delegate + kind: test + hooks: [token.delegate] +routes: + any: + policy: + - "delegate(capless-delegate, target: workday-api)" +"#; + let (mgr, cfg, cache) = build_setup( + yaml, + vec![("capless-delegate".to_string(), Arc::clone(&plugin), plugin_cfg)], + ) + .await; + + let route = cfg.routes.get("any").expect("route present"); + let registry = cfg.plugins.clone(); + let plan = cache.get_or_build(route, ®istry, &mgr).await; + + let extensions = ext_with_subject_and_label("eyJ.fake.jwt", "alice", "pii"); + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "any", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await); + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + let mut payload = RoutePayload::new(serde_json::Value::Null); + let _ = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + ) + .await; + + let obs = observed + .lock() + .unwrap() + .clone() + .expect("plugin should have run"); + + // Load-bearing negative assertions — no cap → no slot. + assert!( + obs.saw_subject_id.is_none(), + "without read_subject, subject must be hidden — saw: {:?}", + obs.saw_subject_id, + ); + assert!( + obs.saw_labels.is_empty(), + "without read_labels, labels must be hidden", + ); + assert!( + !obs.saw_inbound_token_for_user, + "without read_inbound_credentials, inbound token must be hidden", + ); +} + diff --git a/crates/apl-cpex/tests/end_to_end_route.rs b/crates/apl-cpex/tests/end_to_end_route.rs new file mode 100644 index 00000000..184c8149 --- /dev/null +++ b/crates/apl-cpex/tests/end_to_end_route.rs @@ -0,0 +1,551 @@ +// Location: ./crates/apl-cpex/tests/end_to_end_route.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end integration: APL YAML config → `compile_config` → +// `evaluate_route` → `CmfPluginInvoker::invoke` → typed CPEX dispatch +// via `invoke_named::` → real plugin handler → result mapped +// back through apl-core's `Decision`. +// +// This is the load-bearing test for v0 — it proves apl-core + +// apl-cpex + cpex-core compose through their public surfaces. +// +// The earlier `cmf_invoker_dispatch.rs` exercised the invoker +// directly. This file goes one layer up: the host writes a tiny APL +// route YAML, the evaluator drives the route, and the invoker is the +// only thing that translates plugin-named steps into CMF hook calls. + +use std::sync::Arc; + +use async_trait::async_trait; +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError as CoreError, PluginViolation}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use apl_core::pipeline::TaintScope; +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, NoopDelegationInvoker, PdpCall, + PdpDecision, PdpDialect, PdpError, PdpResolver, RoutePayload, +}; + +use apl_cpex::{CmfPluginInvoker, DispatchCache, MemorySessionStore, SessionStore}; + +// --------------------------------------------------------------------- +// Stub PDP — apl-core requires `&dyn PdpResolver`, but no scenario in +// this file exercises a PDP step, so an always-allow stub is enough. +// --------------------------------------------------------------------- + +struct AllowPdp; + +#[async_trait] +impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { + decision: Decision::Allow, + diagnostics: vec![], + }) + } +} + +// --------------------------------------------------------------------- +// Test CMF plugins — minimal handlers registered on `cmf.tool_pre_invoke` +// (the hook `CmfPluginInvoker` dispatches `PluginInvocation::Step` to +// by default). Duplicated from `cmf_invoker_dispatch.rs` because cargo +// test files don't share modules without a `tests/common/` layout, and +// the fixtures are tiny enough that mild duplication beats the layout +// churn for v0. +// --------------------------------------------------------------------- + +struct AllowPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +struct AllowPluginFactory; +impl PluginFactory for AllowPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AllowPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +struct DenyPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "policy.forbidden", + "scope-gate fixture denied this call", + )) + } +} + +struct DenyPluginFactory; +impl PluginFactory for DenyPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(DenyPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + +async fn manager_with( + kind: &str, + factory: Box, +) -> Arc { + let mgr = PluginManager::default(); + mgr.register_factory(kind, factory); + let yaml = format!("plugins:\n - name: {0}\n kind: {0}\n", kind); + let cfg = cpex_core::config::parse_config(&yaml).expect("parse_config"); + mgr.load_config(cfg).expect("load_config"); + mgr.initialize().await.expect("initialize"); + Arc::new(mgr) +} + +fn empty_payload() -> RoutePayload { + RoutePayload::new(serde_json::json!({})) +} + +fn cmf_payload() -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, "irrelevant for v0 step-only test"), + } +} + +// --------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------- + +/// Route with one policy step `plugin(scope-gate)`. The CPEX plugin +/// registered under that name returns `allow()`. `evaluate_route` must +/// therefore return `Decision::Allow` end-to-end. The hook name is now +/// resolved from the root `plugins:` block in YAML — no hardcoded +/// defaults on the invoker. +#[tokio::test] +async fn route_with_allow_plugin_evaluates_allow() { + const YAML: &str = r#" +plugins: + - name: scope-gate + kind: scope-gate + hooks: [cmf.tool_pre_invoke] +routes: + get_weather: + policy: + - "plugin(scope-gate)" +"#; + + let mgr = manager_with("scope-gate", Box::new(AllowPluginFactory)).await; + let cfg = compile_config(YAML).expect("compile_config"); + let route = cfg.routes.get("get_weather").expect("route present"); + let cache = DispatchCache::new(); + let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; + let invoker = Arc::new(CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + cmf_payload(), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await); + + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = + evaluate_route(route, &mut bag, &mut payload, &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc)).await; + + assert_eq!(decision.decision, Decision::Allow); + assert!(decision.taints.is_empty()); + assert!(!decision.args_modified); + assert!(!decision.result_modified); +} + +/// Same route shape, but the CPEX plugin denies. `evaluate_route` must +/// surface that as `Decision::Deny` with the violation reason + code +/// flowed through `CmfPluginInvoker`. +#[tokio::test] +async fn route_with_deny_plugin_surfaces_violation_through_route_decision() { + const YAML: &str = r#" +plugins: + - name: scope-gate + kind: scope-gate + hooks: [cmf.tool_pre_invoke] +routes: + get_weather: + policy: + - "plugin(scope-gate)" +"#; + + let mgr = manager_with("scope-gate", Box::new(DenyPluginFactory)).await; + let cfg = compile_config(YAML).expect("compile_config"); + let route = cfg.routes.get("get_weather").expect("route present"); + let cache = DispatchCache::new(); + let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; + let invoker = Arc::new(CmfPluginInvoker::for_request( + mgr, + Extensions::default(), + cmf_payload(), + plan, + Arc::new(MemorySessionStore::new()), + ) + .await); + + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = + evaluate_route(route, &mut bag, &mut payload, &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc)).await; + + match decision.decision { + Decision::Deny { + reason, + rule_source, + } => { + assert_eq!( + reason.as_deref(), + Some("scope-gate fixture denied this call"), + "violation reason should flow back through CmfPluginInvoker → \ + PluginOutcome → evaluate_steps → RouteDecision" + ); + assert_eq!(rule_source, "policy.forbidden"); + } + other => panic!("expected Decision::Deny, got {:?}", other), + } +} + +// --------------------------------------------------------------------- +// Taint extraction — plugin adds a security label via cow_copy + +// modify_extensions; invoker diffs labels, surfaces the new ones as +// TaintEvent in PluginOutcome.taints. evaluate_steps accumulates them +// into RouteDecision.taints. SessionStore receives the new label via +// persist_session. +// --------------------------------------------------------------------- + +struct TaintingPlugin { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for TaintingPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for TaintingPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // cow_copy gives an OwnedExtensions handle inheriting any write + // tokens the executor set up (append_labels grants the + // labels_write_token automatically because the registration + // declares the capability). + let mut owned = extensions.cow_copy(); + let security = owned + .security + .get_or_insert_with(Default::default); + security.add_label("PII"); + PluginResult::modify_extensions(owned) + } +} + +struct TaintingPluginFactory; +impl PluginFactory for TaintingPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(TaintingPlugin { + cfg: config.clone(), + }); + Ok(PluginInstance { + plugin: plugin.clone(), + handlers: vec![( + "cmf.tool_pre_invoke", + Arc::new(TypedHandlerAdapter::::new(plugin)), + )], + }) + } +} + +/// Build a manager whose registered plugin has `append_labels` capability, +/// without which the executor would refuse the modified labels on the way +/// out (label monotonicity is enforced under the write-token system). +async fn tainting_manager() -> Arc { + let mgr = PluginManager::default(); + mgr.register_factory("tagger", Box::new(TaintingPluginFactory)); + let yaml = "plugins:\n - name: tagger\n kind: tagger\n capabilities: [append_labels, read_labels]\n"; + let cfg = cpex_core::config::parse_config(yaml).expect("parse_config"); + mgr.load_config(cfg).expect("load_config"); + mgr.initialize().await.expect("initialize"); + Arc::new(mgr) +} + +#[tokio::test] +async fn route_plugin_emitting_label_surfaces_taint_and_persists_to_session() { + const YAML: &str = r#" +plugins: + - name: tagger + kind: tagger + hooks: [cmf.tool_pre_invoke] + capabilities: [append_labels, read_labels] +routes: + classify: + policy: + - "plugin(tagger)" +"#; + + let mgr = tainting_manager().await; + let cfg = compile_config(YAML).expect("compile_config"); + let route = cfg.routes.get("classify").expect("route present"); + let cache = DispatchCache::new(); + let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; + + // Session id pinned via tier-0 (agent.session_id) — lets the test + // specify an exact value without faking the identity hash. + let mut agent = cpex_core::extensions::AgentExtension::default(); + agent.session_id = Some("sess-taint-test".into()); + let extensions = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let session_store = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new(CmfPluginInvoker::for_request( + mgr, + extensions, + cmf_payload(), + plan, + session_store.clone(), + ) + .await); + + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = + evaluate_route(route, &mut bag, &mut payload, &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc)).await; + + // Decision flows through allow (plugin's modify_extensions doesn't + // halt the pipeline). + assert_eq!(decision.decision, Decision::Allow); + + // The label-emit traveled the full path: + // plugin.handle → modify_extensions → + // PipelineResult.modified_extensions → + // CmfPluginInvoker.invoke (label diff) → + // PluginOutcome.taints → + // evaluate_steps_inner accumulator → + // StepsEvaluation.taints → + // evaluate_route → RouteDecision.taints + assert_eq!(decision.taints.len(), 1, "expected one taint event from tagger plugin"); + let event = &decision.taints[0]; + assert_eq!(event.label, "PII"); + assert_eq!(event.scopes, vec![TaintScope::Session]); + + // SessionStore persistence — host calls persist_session after route + // evaluation; new labels (vs the post-hydration snapshot) land in + // the store under the request's session_id. + invoker.persist_session().await; + let stored = session_store.load_labels("sess-taint-test").await; + assert_eq!(stored, vec!["PII".to_string()]); +} + +#[tokio::test] +async fn session_store_hydrates_labels_at_request_start() { + // Pre-seed the session store with a label, then verify the invoker + // hydrates it into extensions.security.labels at for_request time + // (so the first plugin call sees the accumulated session state). + let session_store = Arc::new(MemorySessionStore::new()); + session_store + .append_labels("sess-existing", &["PRIOR".to_string()]) + .await; + + let mgr = tainting_manager().await; + let yaml = r#" +plugins: + - name: tagger + kind: tagger + hooks: [cmf.tool_pre_invoke] + capabilities: [append_labels, read_labels] +routes: + classify: + policy: + - "plugin(tagger)" +"#; + let cfg = compile_config(yaml).expect("compile_config"); + let route = cfg.routes.get("classify").unwrap(); + let plan = DispatchCache::new().get_or_build(route, &cfg.plugins, &mgr).await; + + let mut agent = cpex_core::extensions::AgentExtension::default(); + agent.session_id = Some("sess-existing".into()); + let extensions = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let invoker = Arc::new( + CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) + .await, + ); + + // Hydrated labels should be observable on the invoker's extensions. + let snapshot = invoker.current_extensions().await; + let security = snapshot.security.expect("hydration creates security extension"); + assert!(security.has_label("PRIOR"), "hydration should pull PRIOR from session store"); + + // Now drive a route — tagger adds PII. After persist, the store has + // both PRIOR (from hydration) and PII (newly emitted). + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = + evaluate_route(route, &mut bag, &mut payload, &(Arc::new(AllowPdp) as Arc), &(invoker.clone() as Arc), &(Arc::new(NoopDelegationInvoker) as Arc)).await; + assert_eq!(decision.decision, Decision::Allow); + + // Only the NEW label (PII) shows up as a taint — PRIOR was already + // present before the plugin ran, so it's not a fresh emission. + assert_eq!(decision.taints.len(), 1); + assert_eq!(decision.taints[0].label, "PII"); + + invoker.persist_session().await; + let mut stored = session_store.load_labels("sess-existing").await; + stored.sort(); + assert_eq!(stored, vec!["PII".to_string(), "PRIOR".to_string()]); +} + +/// Slice TS1 proof: an APL `taint(audit, session)` step lands the +/// label in `security.labels` (via `apply_session_taints`) AND the +/// SessionStore (via `persist_session`). No plugin is involved — the +/// taint comes from the YAML, not from any handler's modify_extensions. +/// This is the load-bearing end-to-end test for the +/// "policy with side-effects" pitch: writing `taint(...)` in YAML +/// actually causes the session to be permanently labelled. +#[tokio::test] +async fn apl_taint_step_lands_in_security_labels_and_persists() { + const YAML: &str = r#" +routes: + classify: + policy: + - "taint(audit, session)" +"#; + + let mgr = manager_with("noop", Box::new(AllowPluginFactory)).await; + let cfg = compile_config(YAML).expect("compile_config"); + let route = cfg.routes.get("classify").expect("route present"); + let plan = DispatchCache::new().get_or_build(route, &cfg.plugins, &mgr).await; + + let mut agent = cpex_core::extensions::AgentExtension::default(); + agent.session_id = Some("sess-apl-taint".into()); + let extensions = Extensions { + agent: Some(Arc::new(agent)), + ..Default::default() + }; + + let session_store = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new( + CmfPluginInvoker::for_request(mgr, extensions, cmf_payload(), plan, session_store.clone()) + .await, + ); + + let mut bag = AttributeBag::new(); + let mut payload = empty_payload(); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(Arc::new(NoopDelegationInvoker) as Arc), + ) + .await; + assert_eq!(decision.decision, Decision::Allow); + + // Evaluator surfaced the YAML taint into the decision. + assert_eq!(decision.taints.len(), 1, "expected one taint from `taint(...)` step"); + assert_eq!(decision.taints[0].label, "audit"); + assert!(decision.taints[0] + .scopes + .contains(&TaintScope::Session)); + + // This is the new wiring: drain Session-scoped taints into + // `security.labels` exactly as `AplRouteHandler::invoke` does. + invoker.apply_session_taints(&decision.taints).await; + + let snapshot = invoker.current_extensions().await; + let security = snapshot + .security + .as_ref() + .expect("apply_session_taints should have created the security ext"); + assert!( + security.has_label("audit"), + "session-scoped taint should land in security.labels", + ); + + // And `persist_session` should pick up the label via the diff + // against `initial_labels` (which was empty here). + invoker.persist_session().await; + let stored = session_store.load_labels("sess-apl-taint").await; + assert_eq!(stored, vec!["audit".to_string()]); +} diff --git a/crates/apl-cpex/tests/visitor_e2e.rs b/crates/apl-cpex/tests/visitor_e2e.rs new file mode 100644 index 00000000..c3ca4cde --- /dev/null +++ b/crates/apl-cpex/tests/visitor_e2e.rs @@ -0,0 +1,705 @@ +// Location: ./crates/apl-cpex/tests/visitor_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end integration: unified-config YAML → cpex-core +// `load_config_yaml` → `AplConfigVisitor` walks global / defaults / tags +// / routes → `PluginManager::annotate_route` installs phase-bound +// `AplRouteHandler`s → host calls `invoke_named::` with meta → +// route-annotation short-circuit fires the handler → APL evaluator runs +// the layered route → real CPEX plugins dispatch through +// `CmfPluginInvoker` inside the handler. +// +// This is the load-bearing test for the visitor + annotation flow. It +// proves the whole hierarchy collapses into per-route handlers exactly +// once at load time, and that dispatch into those handlers behaves like +// any other plugin entry (mode, on_error, capabilities all honored +// because the synthetic plugin's `PluginConfig` flows through the same +// executor path). + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError as CoreError, PluginViolation}; +use cpex_core::extensions::MetaExtension; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; + +// ===================================================================== +// Test plugins — `allow-gate` (passes through) and `deny-gate` (denies). +// Both register on `cmf.tool_pre_invoke`. APL routes reference them by +// name via `plugin()` in the YAML; the visitor stacks them into +// the route's compiled steps; the handler dispatches into them through +// CmfPluginInvoker. +// ===================================================================== + +struct AllowGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +struct AllowGateFactory; +impl PluginFactory for AllowGateFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(AllowGate { + cfg: config.clone(), + }); + // Register the handler under every hook the operator declared + // in `hooks: [...]`. Lets tests pin the plugin to llm / prompt + // / resource hooks via YAML without per-entity factory copies. + let handlers = hooks_for(config, plugin.clone()); + Ok(PluginInstance { + plugin, + handlers, + }) + } +} + +/// Build the adapter list for a plugin from the operator-declared +/// `hooks:` config. Falls back to `cmf.tool_pre_invoke` when nothing +/// is declared (matches v0 default for routes that don't specify). +fn hooks_for( + config: &PluginConfig, + plugin: Arc, +) -> Vec<( + &'static str, + Arc, +)> +where + H: HookHandler + Plugin + 'static, +{ + let hook_names: Vec<&'static str> = if config.hooks.is_empty() { + vec!["cmf.tool_pre_invoke"] + } else { + config + .hooks + .iter() + .map(|s| Box::leak(s.clone().into_boxed_str()) as &'static str) + .collect() + }; + hook_names + .into_iter() + .map(|name| { + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + (name, adapter) + }) + .collect() +} + +struct DenyGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "policy.forbidden", + "deny-gate fired", + )) + } +} + +struct DenyGateFactory; +impl PluginFactory for DenyGateFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let plugin = Arc::new(DenyGate { + cfg: config.clone(), + }); + let handlers = hooks_for(config, plugin.clone()); + Ok(PluginInstance { + plugin, + handlers, + }) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn cmf_payload(text: &str) -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, text), + } +} + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".to_string()); + meta.entity_name = Some(name.to_string()); + meta +} + +/// Build a manager with `allow-gate` and `deny-gate` factories registered, +/// then wire the APL visitor in via `register_apl`. Returns +/// `Arc` so the caller can dispatch through +/// `invoke_named`. The visitor self-populates its plugin registry from +/// cpex-core's parsed `Vec` via `visit_plugins` — no host +/// pre-parse needed. +async fn build_manager_with_visitor(yaml: &str) -> Arc { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory("allow-gate", Box::new(AllowGateFactory)); + mgr.register_factory("deny-gate", Box::new(DenyGateFactory)); + + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + base_capabilities: None, + }, + ); + + mgr.load_config_yaml(yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + mgr +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Route declares an `apl.policy: [plugin(allow-gate)]`. After the +/// visitor walks the config, `cmf.tool_pre_invoke` for tool `get_weather` +/// must short-circuit to the APL handler, which dispatches the policy +/// step into the registered `allow-gate` plugin → allow. +#[tokio::test] +async fn visitor_route_with_allow_plugin_returns_allow() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + assert!( + result.continue_processing, + "allow path should continue: violation = {:?}", + result.violation + ); +} + +/// Same shape but with `deny-gate`. The visitor compiles the route, +/// annotates the manager, dispatch goes through the handler, the handler +/// calls into deny-gate via CmfPluginInvoker, the violation propagates +/// out as `PipelineResult.violation` with the original code + reason. +#[tokio::test] +async fn visitor_route_with_deny_plugin_propagates_violation() { + const YAML: &str = r#" +plugins: + - name: deny-gate + kind: deny-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(deny-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + assert!(!result.continue_processing, "deny path should halt"); + let violation = result.violation.expect("deny path must surface a violation"); + assert_eq!( + violation.reason, "deny-gate fired", + "violation reason must propagate from the plugin through the handler" + ); + assert_eq!(violation.code, "policy.forbidden"); +} + +/// Hierarchy: global APL policy step runs FIRST, then route APL policy. +/// Tests apply_layer ordering — global's `plugin(allow-gate)` runs and +/// passes, then route's `plugin(deny-gate)` fires and denies. If the +/// global layer had been appended after instead of before, the deny +/// would have run first and we'd see the deny path; the order assertion +/// is implicit in the violation reason coming from deny-gate. +#[tokio::test] +async fn visitor_stacks_global_then_route_in_order() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] + - name: deny-gate + kind: deny-gate + hooks: [cmf.tool_pre_invoke] +global: + apl: + policy: + - "plugin(allow-gate)" +routes: + - tool: get_weather + apl: + policy: + - "plugin(deny-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + let violation = result.violation.expect("route-level deny must fire"); + assert_eq!(violation.reason, "deny-gate fired"); +} + +/// Tag bundle stacks on top of global. A route tagged `pii` inherits +/// `plugin(deny-gate)` from the tag bundle even though the route itself +/// declares no APL block — proves tag layers are applied without the +/// route having to redeclare anything. +#[tokio::test] +async fn visitor_applies_tag_bundle_to_tagged_route() { + const YAML: &str = r#" +plugins: + - name: deny-gate + kind: deny-gate + hooks: [cmf.tool_pre_invoke] +global: + policies: + pii: + apl: + policy: + - "plugin(deny-gate)" +routes: + - tool: get_weather + meta: + tags: [pii] +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_weather"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + let violation = result + .violation + .expect("tag bundle's deny-gate should propagate"); + assert_eq!(violation.reason, "deny-gate fired"); +} + +/// Scope routing: a scoped annotation overrides the unscoped default for +/// the matching scope, while requests in other scopes fall back to the +/// unscoped annotation. Proves the visitor's `meta.scope` propagation is +/// keying annotations correctly through cpex-core's annotation table. +#[tokio::test] +async fn visitor_scoped_annotation_overrides_unscoped() { + // Two routes for the same tool: one scoped to `vs-a`, one unscoped. + // The scoped route denies; the unscoped route allows. A request in + // scope `vs-a` must hit the scoped annotation (deny); a request in + // scope `vs-b` falls back to the unscoped default (allow). + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] + - name: deny-gate + kind: deny-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + meta: + scope: vs-a + apl: + policy: + - "plugin(deny-gate)" + - tool: get_weather + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + // Scope vs-a → scoped annotation → deny. + let mut meta_a = meta_for_tool("get_weather"); + meta_a.scope = Some("vs-a".to_string()); + let ext_a = Extensions { + meta: Some(Arc::new(meta_a)), + ..Default::default() + }; + let (res_a, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext_a, None) + .await; + let v = res_a.violation.expect("scoped annotation should deny"); + assert_eq!(v.reason, "deny-gate fired"); + + // Scope vs-b → no scoped match → fall back to unscoped annotation → allow. + let mut meta_b = meta_for_tool("get_weather"); + meta_b.scope = Some("vs-b".to_string()); + let ext_b = Extensions { + meta: Some(Arc::new(meta_b)), + ..Default::default() + }; + let (res_b, _) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext_b, None) + .await; + assert!( + res_b.continue_processing, + "unscoped fall-back should allow (got violation: {:?})", + res_b.violation + ); +} + +/// Sanity-check: an empty plugin registry + no APL blocks anywhere +/// means the visitor installs zero annotations and the manager behaves +/// exactly as if no visitor was registered. Smokes the no-op path. +#[tokio::test] +async fn visitor_with_no_apl_blocks_installs_nothing() { + // No `apl:` blocks anywhere — just a route + plugin that wouldn't + // be referenced from any APL step. + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: anything +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("anything"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext, + None, + ) + .await; + + // Without APL annotations the route resolves through the legacy + // chain. allow-gate is registered but the route doesn't reference + // it, so it doesn't fire either. The pipeline returns allow. + assert!(result.continue_processing); + assert!(result.violation.is_none()); +} + +/// Smoke test that the visitor surfaces a compile error from a malformed +/// APL block as a `PluginError::Config` out of `load_config_yaml`. Catches +/// regressions where visitor errors swallow into Ok(_) or panic. +// --------------------------------------------------------------------- +// Slice 102 — multi-entity-type route support (llm / prompt / resource) +// --------------------------------------------------------------------- +// +// Pre-Slice-102, the visitor hardcoded annotation on +// `cmf.tool_pre_invoke` / `cmf.tool_post_invoke` regardless of route +// entity_type — so an `llm:` route would silently bind to the tool +// hooks and never fire when the host called `invoke_named::("cmf.llm_input", ...)`. +// These tests pin per-entity routing. + +fn meta_for_entity(entity_type: &str, entity_name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some(entity_type.to_string()); + meta.entity_name = Some(entity_name.to_string()); + meta +} + +/// `llm:` route → annotation lands on `cmf.llm_input`. Host calling +/// `invoke_named::("cmf.llm_input", ...)` with matching meta +/// fires the AplRouteHandler. +#[tokio::test] +async fn llm_route_annotates_on_llm_input_hook() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.llm_input] +routes: + - llm: gpt-4 + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("llm", "gpt-4"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.llm_input", cmf_payload("hi"), ext, None) + .await; + + assert!( + result.continue_processing, + "llm route should fire on cmf.llm_input: violation = {:?}", + result.violation + ); +} + +/// Same llm route but post — annotation lands on `cmf.llm_output`. +/// Pre-Slice-102, this would have annotated on `cmf.tool_post_invoke` +/// and never matched. +#[tokio::test] +async fn llm_route_annotates_on_llm_output_hook_for_post_phase() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.llm_output] +routes: + - llm: gpt-4 + apl: + post_policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("llm", "gpt-4"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.llm_output", cmf_payload("response"), ext, None) + .await; + + assert!( + result.continue_processing, + "llm route post-phase should fire on cmf.llm_output: violation = {:?}", + result.violation + ); +} + +/// `prompt:` route → annotation lands on `cmf.prompt_pre_invoke`. +#[tokio::test] +async fn prompt_route_annotates_on_prompt_pre_invoke_hook() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.prompt_pre_invoke] +routes: + - prompt: summarize_email + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("prompt", "summarize_email"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.prompt_pre_invoke", cmf_payload("hi"), ext, None) + .await; + + assert!( + result.continue_processing, + "prompt route should fire on cmf.prompt_pre_invoke: violation = {:?}", + result.violation + ); +} + +/// `resource:` route → annotation lands on `cmf.resource_pre_fetch`. +#[tokio::test] +async fn resource_route_annotates_on_resource_pre_fetch_hook() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.resource_pre_fetch] +routes: + - resource: hr://employees/* + apl: + policy: + - "plugin(allow-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("resource", "hr://employees/E001234"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.resource_pre_fetch", cmf_payload("hi"), ext, None) + .await; + + assert!( + result.continue_processing, + "resource route should fire on cmf.resource_pre_fetch: violation = {:?}", + result.violation + ); +} + +/// Cross-check: an llm route's APL annotation MUST NOT install on +/// `cmf.tool_pre_invoke`. Pre-Slice-102, the visitor would have +/// annotated llm routes on the tool hook by mistake; this test pins +/// that the bug is gone. +/// +/// Setup: plugin registered ONLY under `cmf.llm_input`. The llm +/// route's APL annotation lands (post-Slice-102) on `cmf.llm_input`. +/// Calling `invoke_named::("cmf.tool_pre_invoke", ...)` +/// finds no APL annotation for that hook AND no plugin chain entry +/// for it → returns `continue_processing=true` with no violations. +/// Calling `cmf.llm_input` DOES fire the annotation and the deny. +#[tokio::test] +async fn llm_route_does_not_fire_on_tool_hook() { + const YAML: &str = r#" +plugins: + - name: deny-gate + kind: deny-gate + hooks: [cmf.llm_input] +routes: + - llm: gpt-4 + apl: + policy: + - "plugin(deny-gate)" +"#; + let mgr = build_manager_with_visitor(YAML).await; + let ext = Extensions { + meta: Some(Arc::new(meta_for_entity("llm", "gpt-4"))), + ..Default::default() + }; + + // Calling cmf.tool_pre_invoke must NOT trigger the llm route's + // APL annotation. With no annotation AND no plugin registered on + // cmf.tool_pre_invoke, dispatch returns continue. + let (tool_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload("hi"), + ext.clone(), + None, + ) + .await; + assert!( + tool_result.continue_processing, + "llm route MUST NOT bind to cmf.tool_pre_invoke (pre-Slice-102 bug); \ + violation = {:?}", + tool_result.violation, + ); + + // Sanity: calling the RIGHT hook (cmf.llm_input) DOES fire the + // annotation, hits deny-gate, denies — proves the route is wired + // correctly on the llm hook side. + let (llm_result, _bg) = mgr + .invoke_named::("cmf.llm_input", cmf_payload("hi"), ext, None) + .await; + assert!( + !llm_result.continue_processing, + "cmf.llm_input dispatch should hit the deny-gate via the llm route", + ); +} + +#[tokio::test] +async fn visitor_compile_error_propagates_from_load_config_yaml() { + const YAML: &str = r#" +plugins: + - name: allow-gate + kind: allow-gate + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "this-is-not-a-valid-step ::: $$$" +"#; + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory("allow-gate", Box::new(AllowGateFactory)); + register_apl(&mgr, AplOptions::in_process()); + + let err = mgr.load_config_yaml(YAML).expect_err("malformed APL block must error"); + let msg = format!("{}", err); + assert!( + msg.contains("visitor 'apl'"), + "expected visitor error context, got: {}", + msg + ); +} diff --git a/crates/apl-delegator-biscuit/Cargo.toml b/crates/apl-delegator-biscuit/Cargo.toml new file mode 100644 index 00000000..f040f614 --- /dev/null +++ b/crates/apl-delegator-biscuit/Cargo.toml @@ -0,0 +1,67 @@ +# Location: ./crates/apl-delegator-biscuit/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-delegator-biscuit — `TokenDelegateHandler` that performs +# biscuit-auth capability-token attenuation. +# +# # Why this exists +# +# Slice 3's `TokenDelegateHook` defines the surface; this crate is +# the *decentralized* backend — cryptographic scope attenuation +# without an IdP roundtrip. Reads the inbound biscuit, appends a +# delegation block narrowing the capabilities (resource / operation +# / time-bound checks), produces a new biscuit base64-encoded as +# the outbound credential. +# +# # AIP alignment +# +# The IETF draft `draft-prakash-aip-00` (Agent Identity Protocol) +# defines a "Chained Mode" using biscuit tokens with +# authority/delegation/completion blocks. This crate produces the +# delegation-block half of that flow; the authority block is the +# inbound biscuit; completion blocks land in a future post-result +# audit hook. +# +# # When to reach for this vs `apl-delegator-oauth` +# +# - **`apl-delegator-biscuit`** — capability tokens, cryptographic +# attenuation, no IdP roundtrip. Use for federated agent +# ecosystems where there's no shared IdP, or for performance- +# sensitive paths where the IdP roundtrip cost matters. +# - **`apl-delegator-oauth`** (slice 6) — RFC 8693 against an +# OAuth IdP. Use when centralized audit/revocation matters more +# than roundtrip cost. + +[package] +name = "apl-delegator-biscuit" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# biscuit-auth v6 — current major. Maintained by Clever Cloud + +# community. Ed25519 + Datalog. No default-features off needed; the +# default feature set is reasonable (no Tonic/network deps). +biscuit-auth = "6" + +# `hex` for parsing raw 32-byte Ed25519 public keys from config. +# biscuit-auth doesn't re-export hex parsing. +hex = "0.4" + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-delegator-biscuit/src/config.rs b/crates/apl-delegator-biscuit/src/config.rs new file mode 100644 index 00000000..16c3ac0d --- /dev/null +++ b/crates/apl-delegator-biscuit/src/config.rs @@ -0,0 +1,161 @@ +// Location: ./crates/apl-delegator-biscuit/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed configuration for `BiscuitDelegator`. + +use std::path::PathBuf; + +use biscuit_auth::PublicKey; +use serde::{Deserialize, Serialize}; + +/// Plugin config — what operators write under +/// `plugins[].config:` in unified-config YAML. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BiscuitDelegatorConfig { + /// The root public key the inbound biscuit was signed against. + /// Verification fails if the inbound's authority-block signature + /// doesn't validate under this key. + pub root_public_key: PublicKeySource, + + /// Header name the forwarding plugin should attach the minted + /// token under. Most downstream services expect + /// `Authorization` or a custom `X-AIP-Token`-style header. + #[serde(default = "default_outbound_header")] + pub default_outbound_header: String, + + /// Default TTL for the appended delegation block, in seconds. + /// Per-call overrides come from `AttenuationConfig.ttl_seconds` + /// on the `DelegationPayload`. + #[serde(default = "default_ttl_seconds")] + pub default_ttl_seconds: u64, +} + +/// Where the root public key is loaded from. Three modes: +/// +/// * **`hex`** — 32-byte Ed25519 public key encoded as 64 hex +/// characters. Convenient for testing and dev configs. +/// * **`file`** — path to a file containing the raw 32-byte key +/// (binary) or its hex encoding (with optional newline). The +/// resolver auto-detects which. +/// * **`bytes`** — inline 32-byte raw key. Rarely used directly +/// in YAML (operators prefer hex or file) but available for +/// programmatic construction. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PublicKeySource { + Hex { hex: String }, + File { path: PathBuf }, + Bytes { bytes: Vec }, +} + +fn default_outbound_header() -> String { + "Authorization".to_string() +} + +fn default_ttl_seconds() -> u64 { + 300 +} + +impl PublicKeySource { + /// Turn the serializable source into a runtime `PublicKey`. + /// Returns a string error so the caller wraps in + /// `PluginError::Config` with context. + pub fn resolve(&self) -> Result { + match self { + Self::Hex { hex } => { + let bytes = hex::decode(hex.trim()) + .map_err(|e| format!("public_key.hex isn't valid hex: {e}"))?; + Self::bytes_to_public_key(&bytes) + } + Self::Bytes { bytes } => Self::bytes_to_public_key(bytes), + Self::File { path } => { + let raw = std::fs::read(path).map_err(|e| { + format!("public_key file '{}' unreadable: {e}", path.display()) + })?; + // File might be raw 32 bytes OR a hex string (with + // optional whitespace). Try raw first; fall back to + // hex if the length doesn't match. + if raw.len() == 32 { + Self::bytes_to_public_key(&raw) + } else { + // Treat as hex with possible whitespace. + let as_str = std::str::from_utf8(&raw).map_err(|e| { + format!( + "public_key file '{}' isn't 32 raw bytes or valid \ + UTF-8 hex: {e}", + path.display() + ) + })?; + let trimmed = as_str.trim(); + let bytes = hex::decode(trimmed).map_err(|e| { + format!( + "public_key file '{}' isn't valid hex: {e}", + path.display() + ) + })?; + Self::bytes_to_public_key(&bytes) + } + } + } + } + + fn bytes_to_public_key(bytes: &[u8]) -> Result { + if bytes.len() != 32 { + return Err(format!( + "Ed25519 public key must be 32 bytes; got {}", + bytes.len() + )); + } + PublicKey::from_bytes(bytes, biscuit_auth::Algorithm::Ed25519) + .map_err(|e| format!("public key bytes not a valid Ed25519 key: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use biscuit_auth::KeyPair; + + #[test] + fn hex_source_resolves() { + let kp = KeyPair::new(); + let pub_hex = hex::encode(kp.public().to_bytes()); + let src = PublicKeySource::Hex { hex: pub_hex }; + assert!(src.resolve().is_ok()); + } + + #[test] + fn hex_source_rejects_wrong_length() { + let src = PublicKeySource::Hex { + hex: "deadbeef".into(), // 4 bytes — wrong length + }; + let err = src.resolve().unwrap_err(); + assert!(err.contains("32 bytes")); + } + + #[test] + fn hex_source_rejects_garbage() { + let src = PublicKeySource::Hex { + hex: "not hex".into(), + }; + let err = src.resolve().unwrap_err(); + assert!(err.contains("hex")); + } + + #[test] + fn config_deserializes() { + let kp = KeyPair::new(); + let pub_hex = hex::encode(kp.public().to_bytes()); + let raw = serde_json::json!({ + "root_public_key": { "kind": "hex", "hex": pub_hex }, + "default_outbound_header": "X-AIP-Token", + "default_ttl_seconds": 60, + }); + let cfg: BiscuitDelegatorConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.default_outbound_header, "X-AIP-Token"); + assert_eq!(cfg.default_ttl_seconds, 60); + assert!(cfg.root_public_key.resolve().is_ok()); + } +} diff --git a/crates/apl-delegator-biscuit/src/delegator.rs b/crates/apl-delegator-biscuit/src/delegator.rs new file mode 100644 index 00000000..9961886d --- /dev/null +++ b/crates/apl-delegator-biscuit/src/delegator.rs @@ -0,0 +1,279 @@ +// Location: ./crates/apl-delegator-biscuit/src/delegator.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `BiscuitDelegator` — `HookHandler` that +// performs biscuit-auth capability-token attenuation. +// +// # Flow +// +// 1. Decode `payload.bearer_token()` as base64 → biscuit bytes. +// 2. Parse + verify the inbound biscuit against the configured +// root public key (`Biscuit::from(bytes, root_public_key)`). +// 3. Build a delegation block carrying the route's narrowing +// constraints: +// * `delegated_to("")` fact +// * `audience("")` fact +// * `check if operation("")` for each required permission +// * `check if time($t), $t <= ` time-bound +// 4. Append the block via `biscuit.append(block_builder)`. Biscuit +// generates an ephemeral signing keypair internally — the +// verifier walks the chain to validate. +// 5. Serialize the new biscuit (now with one more block) to +// base64 → `RawDelegatedToken`. +// +// # Error handling +// +// Construction errors → `Box` (`PluginError::Config`). +// Runtime errors → `PluginResult::deny(PluginViolation::new(code, +// reason))`: +// * `delegation.bad_request` — missing bearer token / target audience +// * `delegation.token_invalid` — base64 decode failed or biscuit +// verification failed (wrong key, +// tampered signature, malformed) +// * `delegation.attenuation_failed` — block construction failed +// (Datalog syntax error) + +use async_trait::async_trait; +use biscuit_auth::builder::BlockBuilder; +use biscuit_auth::{Biscuit, PublicKey}; +use chrono::Utc; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{DelegationPayload, TokenDelegateHook}; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use super::config::BiscuitDelegatorConfig; + +/// Biscuit-mediated `TokenDelegate` handler. +pub struct BiscuitDelegator { + cfg: PluginConfig, + typed: BiscuitDelegatorConfig, + /// Pre-resolved root public key — verifying every inbound + /// biscuit's authority block. + root_public_key: PublicKey, +} + +impl std::fmt::Debug for BiscuitDelegator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BiscuitDelegator") + .field("cfg", &self.cfg.name) + .field("default_outbound_header", &self.typed.default_outbound_header) + .field("default_ttl_seconds", &self.typed.default_ttl_seconds) + .field("root_public_key", &"") + .finish() + } +} + +impl BiscuitDelegator { + /// Build from `PluginConfig`. Parses `cfg.config` into + /// [`BiscuitDelegatorConfig`] and resolves the root public key. + pub fn new(cfg: PluginConfig) -> Result> { + let raw = cfg.config.as_ref().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-biscuit) requires a `config:` block", + cfg.name + ), + }) + })?; + let typed: BiscuitDelegatorConfig = serde_json::from_value(raw.clone()) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-biscuit) config parse failed: {e}", + cfg.name + ), + }) + })?; + + let root_public_key = typed.root_public_key.resolve().map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-biscuit) root_public_key: {e}", + cfg.name + ), + }) + })?; + + Ok(Self { + cfg, + typed, + root_public_key, + }) + } + + /// Resolve the effective TTL — route hint wins if shorter than + /// the configured default. + fn effective_ttl_seconds(&self, payload: &DelegationPayload) -> u64 { + match payload.route_attenuation().and_then(|a| a.ttl_seconds) { + Some(hint) => hint.min(self.typed.default_ttl_seconds), + None => self.typed.default_ttl_seconds, + } + } +} + +#[async_trait] +impl Plugin for BiscuitDelegator { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for BiscuitDelegator { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let bearer = payload.bearer_token(); + if bearer.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "DelegationPayload carried an empty bearer_token", + )); + } + let audience = payload.target_audience().unwrap_or("").to_string(); + if audience.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "target_audience missing — biscuit attenuation requires \ + an audience to scope the delegation block", + )); + } + + // 1. Decode + parse + verify inbound biscuit. + // `Biscuit::from_base64` handles both URL-safe and + // standard base64 variants internally. + let biscuit = match Biscuit::from_base64(bearer, self.root_public_key) { + Ok(b) => b, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.token_invalid", + format!( + "inbound biscuit verification failed against configured \ + root public key: {e}" + ), + )); + } + }; + + // 2. Build the delegation block. + let ttl_secs = self.effective_ttl_seconds(payload); + let expires_at_unix = (Utc::now() + + chrono::Duration::seconds(ttl_secs as i64)) + .timestamp(); + + // Build the delegation block as a Datalog string. biscuit + // parses + validates the Datalog at parse time. Building + // the source as a single string and parsing once is simpler + // than the typed Fact/Term builder API. + // + // Quote-escape any embedded `"` in user-supplied values so a + // malicious target_name or required_permission can't escape + // the Datalog string literal and inject extra clauses. + let mut datalog = String::new(); + datalog.push_str(&format!( + r#"delegated_to("{}");"#, + escape_datalog_string(payload.target_name()) + )); + datalog.push_str(&format!( + r#"audience("{}");"#, + escape_datalog_string(&audience) + )); + for perm in payload.required_permissions() { + datalog.push_str(&format!( + r#"check if operation("{}");"#, + escape_datalog_string(perm) + )); + } + // Time-bound check — token unusable past expires_at. + datalog.push_str(&format!( + "check if time($t), $t <= {expires_at_unix};" + )); + + // biscuit-auth 6's `BlockBuilder::code` consumes the + // builder and returns a new one on success (or an error if + // the Datalog source is malformed). + let builder = match BlockBuilder::new().code(datalog.as_str()) { + Ok(b) => b, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.attenuation_failed", + format!("delegation block Datalog parse failed: {e}"), + )); + } + }; + + // 3. Append the block. Biscuit generates an ephemeral + // Ed25519 keypair internally for the new block; the + // verifier walks the chain to validate. + let attenuated = match biscuit.append(builder) { + Ok(b) => b, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.attenuation_failed", + format!("biscuit append failed: {e}"), + )); + } + }; + + // 4. Serialize. + let new_bytes = match attenuated.to_base64() { + Ok(s) => s, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.attenuation_failed", + format!("could not serialize attenuated biscuit: {e}"), + )); + } + }; + + // 5. Build RawDelegatedToken. + let scopes: Vec = { + let mut s: Vec = payload.required_permissions().to_vec(); + if let Some(att) = payload.route_attenuation() { + for cap in &att.capabilities { + if !s.contains(cap) { + s.push(cap.clone()); + } + } + } + s + }; + let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs as i64); + let token = RawDelegatedToken::new( + new_bytes, + self.typed.default_outbound_header.clone(), + audience, + scopes, + expires_at, + ); + + let mut updated = payload.clone(); + updated.delegated_token = Some(token); + updated.delegation_mode = Some(DelegationMode::OnBehalfOfUser); + updated.minted_at = Some(Utc::now()); + updated.metadata.insert( + "delegator".into(), + serde_json::Value::String("biscuit".into()), + ); + + PluginResult::modify_payload(updated) + } +} + +/// Escape `"` and `\` in a Datalog string literal so user-supplied +/// values (target name, requested scopes) can't break out of the +/// surrounding `"..."` and inject extra Datalog clauses. Belt-and- +/// suspenders — biscuit's parser would likely reject malformed +/// output but the explicit escape avoids relying on parser behavior. +fn escape_datalog_string(s: &str) -> String { + s.replace('\\', r"\\").replace('"', "\\\"") +} diff --git a/crates/apl-delegator-biscuit/src/lib.rs b/crates/apl-delegator-biscuit/src/lib.rs new file mode 100644 index 00000000..fa5b3e9a --- /dev/null +++ b/crates/apl-delegator-biscuit/src/lib.rs @@ -0,0 +1,33 @@ +// Location: ./crates/apl-delegator-biscuit/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-delegator-biscuit — `TokenDelegateHandler` backed by biscuit +// capability-token attenuation. +// +// The host registers this against `token.delegate`; outbound +// forwarding plugins invoke `mgr.invoke_named::(...)` +// with a `DelegationPayload` whose `bearer_token` is a base64- +// encoded biscuit. This handler parses + verifies the inbound +// biscuit against the configured root public key, appends a +// delegation block that narrows the capabilities per the route's +// requested permissions + audience + TTL, and returns the new +// base64-encoded biscuit as the `RawDelegatedToken`. +// +// # AIP Chained Mode +// +// The output of this delegator is structurally what +// `draft-prakash-aip-00` calls a "Chained Mode" token — authority +// block (the inbound) + one delegation block (our attenuation). +// Subsequent hops can each append further blocks. Completion blocks +// (post-execution audit) are a future hook family. +// +// Sub-step A scope: module structure only. Real implementation in +// sub-step B; integration tests in sub-step C. + +pub mod config; +pub mod delegator; + +pub use config::{BiscuitDelegatorConfig, PublicKeySource}; +pub use delegator::BiscuitDelegator; diff --git a/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs b/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs new file mode 100644 index 00000000..7230da62 --- /dev/null +++ b/crates/apl-delegator-biscuit/tests/biscuit_e2e.rs @@ -0,0 +1,316 @@ +// Location: ./crates/apl-delegator-biscuit/tests/biscuit_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end tests for `BiscuitDelegator`. Generates a root keypair +// in-process, mints an authority-only biscuit (the "inbound"), runs +// the delegator's `handle()`, and verifies that the resulting +// attenuated biscuit is well-formed: the root key still verifies +// the chain, and the new delegation block carries the expected +// `delegated_to` / `audience` / `operation` checks. + +use std::sync::Arc; + +use biscuit_auth::{ + builder::{AuthorizerBuilder, BlockBuilder}, + Biscuit, KeyPair, +}; + +use cpex_core::delegation::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, + HOOK_TOKEN_DELEGATE, +}; +use cpex_core::extensions::raw_credentials::DelegationMode; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use apl_delegator_biscuit::BiscuitDelegator; + +use serde_json::json; + +// ===================================================================== +// Fixtures +// ===================================================================== + +struct Roots { + keypair: KeyPair, +} + +fn roots() -> &'static Roots { + use std::sync::OnceLock; + static ROOTS: OnceLock = OnceLock::new(); + ROOTS.get_or_init(|| Roots { + keypair: KeyPair::new(), + }) +} + +/// Mint a fresh authority-only biscuit carrying the given Datalog +/// (capabilities the principal holds). Returns base64-encoded +/// biscuit ready to hand to the delegator as `bearer_token`. +fn mint_inbound_biscuit(authority_datalog: &str) -> String { + let builder = BlockBuilder::new() + .code(authority_datalog) + .expect("authority Datalog parses"); + Biscuit::builder() + .merge(builder) + .build(&roots().keypair) + .expect("biscuit builds") + .to_base64() + .expect("biscuit serializes") +} + +fn plugin_config() -> PluginConfig { + let pub_hex = hex::encode(roots().keypair.public().to_bytes()); + PluginConfig { + name: "biscuit-delegator".into(), + kind: "test".into(), + hooks: vec![HOOK_TOKEN_DELEGATE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(json!({ + "root_public_key": { "kind": "hex", "hex": pub_hex }, + "default_outbound_header": "Authorization", + "default_ttl_seconds": 300, + })), + ..Default::default() + } +} + +async fn build_manager() -> Arc { + let cfg = plugin_config(); + let delegator = BiscuitDelegator::new(cfg.clone()).expect("delegator constructs"); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::new(delegator), + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +fn build_payload(inbound: String, target: &str, audience: &str, perms: &[&str]) -> DelegationPayload { + DelegationPayload::new(inbound, target) + .with_target_type(TargetType::Tool) + .with_target_audience(audience) + .with_required_permissions(perms.iter().map(|s| s.to_string()).collect()) + .with_auth_enforced_by(AuthEnforcedBy::Target) + .with_route_attenuation(AttenuationConfig { + capabilities: vec!["audit".into()], + resource_template: None, + actions: Vec::new(), + ttl_seconds: Some(120), + }) +} + +async fn invoke( + mgr: &Arc, + payload: DelegationPayload, +) -> cpex_core::executor::PipelineResult { + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + payload, + Extensions::default(), + None, + ) + .await; + result +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Happy path: inbound biscuit + delegation request → attenuated +/// biscuit that still verifies against the root key and carries +/// the expected facts/checks in the new block. +#[tokio::test] +async fn happy_path_attenuates_biscuit() { + let inbound = mint_inbound_biscuit( + r#" + right("read"); + right("audit"); + "#, + ); + + let mgr = build_manager().await; + let payload = build_payload( + inbound.clone(), + "get_compensation", + "https://hr.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "happy path should mint a token: violation = {:?}", + result.violation, + ); + + let final_payload = DelegationPayload::from_pipeline_result(&result) + .expect("delegation payload should be present"); + let minted = final_payload + .delegated_token + .as_ref() + .expect("delegated_token populated"); + + assert_eq!(minted.audience, "https://hr.example.com"); + assert_eq!(minted.outbound_header, "Authorization"); + // The minted bytes are a NEW (longer) biscuit — appending a + // block grows the serialized form. + assert_ne!(&*minted.token, &inbound); + assert!(minted.token.len() > inbound.len()); + + // Verify the chain: the attenuated biscuit must still validate + // against our root public key. + let attenuated = Biscuit::from_base64(&*minted.token, roots().keypair.public()) + .expect("attenuated biscuit verifies against root"); + + // The new biscuit should have one more block than the original. + let original = Biscuit::from_base64(&inbound, roots().keypair.public()) + .expect("inbound verifies"); + assert_eq!(attenuated.block_count(), original.block_count() + 1); + + // Authorize against the matching operation — should succeed + // because the delegation block adds `check if operation("read")` + // and the verifier provides that fact. The Datalog `time(...)` + // fact must be in the past relative to our `check if time(...) + // <= expires_at` predicate, so we pick a tiny value. + let mut authorizer = AuthorizerBuilder::new() + .code(r#"operation("read"); time(0); allow if true;"#) + .expect("authorizer policy parses") + .build(&attenuated) + .expect("authorizer builds against attenuated biscuit"); + authorizer + .authorize() + .expect("authorizer should allow with matching operation"); + + // Mode = OnBehalfOfUser per the biscuit attenuation convention. + assert!(matches!( + final_payload.delegation_mode, + Some(DelegationMode::OnBehalfOfUser), + )); + + // Metadata records the delegator family — useful for audit. + assert_eq!( + final_payload.metadata.get("delegator"), + Some(&json!("biscuit")), + ); +} + +/// Verifier presents a non-matching operation → the +/// `check if operation("read")` from our delegation block fails +/// → authorizer denies. Pins the scope-narrowing invariant: the +/// downstream service can't use the minted token for operations +/// it wasn't granted. +#[tokio::test] +async fn attenuated_token_denies_wrong_operation() { + let inbound = mint_inbound_biscuit(r#"right("read");"#); + let mgr = build_manager().await; + let payload = build_payload( + inbound, + "get_compensation", + "https://hr.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(result.continue_processing); + let final_payload = DelegationPayload::from_pipeline_result(&result).unwrap(); + let minted = final_payload.delegated_token.as_ref().unwrap(); + + let attenuated = Biscuit::from_base64(&*minted.token, roots().keypair.public()).unwrap(); + // Verifier presents `operation("write")` — should fail because + // our delegation block checks for `operation("read")`. + let mut authorizer = AuthorizerBuilder::new() + .code(r#"operation("write"); time(0); allow if true;"#) + .unwrap() + .build(&attenuated) + .unwrap(); + let res = authorizer.authorize(); + assert!( + res.is_err(), + "attenuated token should deny `write` when delegation only allows `read`", + ); +} + +/// Inbound biscuit signed by a DIFFERENT root key than our config +/// trusts → verification fails at parse time → `delegation.token_invalid`. +#[tokio::test] +async fn wrong_root_key_rejects() { + // Mint with a foreign keypair — NOT the one our delegator trusts. + let foreign = KeyPair::new(); + let foreign_biscuit = Biscuit::builder() + .merge(BlockBuilder::new().code(r#"right("read");"#).unwrap()) + .build(&foreign) + .unwrap() + .to_base64() + .unwrap(); + + let mgr = build_manager().await; + let payload = build_payload( + foreign_biscuit, + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.token_invalid"); +} + +/// Empty bearer token → fast-fail input validation, no biscuit +/// parsing attempted. +#[tokio::test] +async fn empty_bearer_token_rejects() { + let mgr = build_manager().await; + let payload = DelegationPayload::new("", "tool") + .with_target_audience("https://downstream.example.com"); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.bad_request"); + assert!(v.reason.contains("empty bearer_token")); +} + +/// Missing target audience — biscuit attenuation needs an audience +/// to scope the delegation block. +#[tokio::test] +async fn missing_audience_rejects() { + let inbound = mint_inbound_biscuit(r#"right("read");"#); + let mgr = build_manager().await; + let payload = DelegationPayload::new(inbound, "tool"); // no audience + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.bad_request"); + assert!(v.reason.contains("target_audience")); +} + +/// Garbage (non-biscuit) bearer token → parse / verify fails → +/// `delegation.token_invalid`. +#[tokio::test] +async fn malformed_bearer_token_rejects() { + let mgr = build_manager().await; + let payload = build_payload( + "this-is-not-a-biscuit".to_string(), + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "delegation.token_invalid"); +} diff --git a/crates/apl-delegator-oauth/Cargo.toml b/crates/apl-delegator-oauth/Cargo.toml new file mode 100644 index 00000000..f4956282 --- /dev/null +++ b/crates/apl-delegator-oauth/Cargo.toml @@ -0,0 +1,69 @@ +# Location: ./crates/apl-delegator-oauth/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-delegator-oauth — `TokenDelegateHandler` that performs RFC 8693 +# OAuth 2.0 token exchange against any compliant IdP. +# +# # Why this exists +# +# Slice 3's `TokenDelegateHook` defines the surface (`DelegationPayload` +# in, `RawDelegatedToken` out via `apply_to_extensions`); this crate +# is the *backend* — the part that actually mints the downstream +# credential. The IdP-mediated path: POST to the IdP's `/token` +# endpoint with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`, +# parse the JSON response, build a `RawDelegatedToken`. +# +# # When to reach for this vs `apl-delegator-biscuit` +# +# - **`apl-delegator-oauth`** (this crate) — IdP-mediated. Use when +# the deployment already runs an OAuth server (Keycloak, Auth0, +# Hydra, Zitadel, Janssen Jans Auth Server) and wants centralized +# audit/revocation. Every delegation costs an IdP roundtrip; +# gateway must hold IdP client credentials. +# - **`apl-delegator-biscuit`** (slice 7) — decentralized capability +# tokens via biscuit attenuation. No IdP roundtrip. Use for +# federated agent ecosystems where there's no shared IdP. +# +# Both implement `HookHandler` and are +# swappable at config time. + +[package] +name = "apl-delegator-oauth" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# `reqwest` for the HTTP POST to the IdP token endpoint. Default +# features pull `rustls` for TLS — we explicitly disable the +# default `default-tls` (native-tls) and pick `rustls-tls` instead +# for a cleaner build on macOS/Linux (no openssl link). +# `json` feature for `.json()` response parsing; we send +# application/x-www-form-urlencoded ourselves (RFC 8693 wants form). +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +serde_urlencoded = "0.7" +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +chrono = { workspace = true } + +# Secret-clearing wrapper for client credentials in memory. +zeroize = { version = "1.8", features = ["zeroize_derive"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +# `mockito` stands up an HTTP server in the test process so we can +# verify the request body shape + simulate IdP responses without +# touching the network. +mockito = "1" diff --git a/crates/apl-delegator-oauth/src/config.rs b/crates/apl-delegator-oauth/src/config.rs new file mode 100644 index 00000000..0a4856b2 --- /dev/null +++ b/crates/apl-delegator-oauth/src/config.rs @@ -0,0 +1,158 @@ +// Location: ./crates/apl-delegator-oauth/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed configuration for `OAuthDelegator`. Deserializes from the +// plugin's `PluginConfig.config: Option` field; the +// delegator's constructor reads this and builds the runtime state +// (the `reqwest::Client`, the loaded client secret). +// +// Serializable intermediate representations stand in for non- +// serializable runtime types (e.g., the secret is loaded from +// env-var / file / literal at construction time, never serialized +// back out). + +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// Top-level plugin config — what operators write under +/// `plugins[].config:` in unified-config YAML. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthDelegatorConfig { + /// IdP's token endpoint URL — where the token-exchange POST + /// lands (e.g., `https://auth.example.com/oauth/token`). + pub token_endpoint: String, + + /// OAuth `client_id` identifying our gateway to the IdP. The + /// IdP authenticates us with `(client_id, client_secret)` over + /// HTTP Basic / form-body before honoring the exchange request. + pub client_id: String, + + /// Where to load the client secret from. See [`ClientSecretSource`]. + pub client_secret_source: ClientSecretSource, + + /// What `subject_token_type` we tell the IdP the inbound token + /// is. RFC 8693 defines `access_token`, `refresh_token`, + /// `id_token`, `jwt`, `saml1`, `saml2`. Most deployments use + /// access_token — that's the default. + #[serde(default = "default_subject_token_type")] + pub subject_token_type: String, + + /// Request timeout. The exchange is on the request hot path — + /// a 5s default keeps requests bounded if the IdP is slow. + #[serde(default = "default_timeout_seconds")] + pub timeout_seconds: u64, + + /// Header name the forwarding plugin should attach the minted + /// token under when calling the downstream service. + /// Most targets expect `Authorization`; some bespoke services + /// want a different header (`X-Service-Token`, etc.). + #[serde(default = "default_outbound_header")] + pub default_outbound_header: String, + + /// Explicitly allow `http://` for `token_endpoint`. By default, + /// the constructor rejects non-https URLs because the + /// token-exchange POST sends `client_id:client_secret` and the + /// inbound user JWT — leaking either over plaintext defeats the + /// whole exchange. Set to `true` ONLY for `http://localhost` + /// development against a docker-compose IdP. Production + /// deployments must leave this at the default (`false`). + #[serde(default)] + pub insecure_http: bool, +} + +/// Where the gateway's OAuth client secret is loaded from. Three +/// modes covering the common deployment patterns: +/// +/// * **`env_var`** — read from a named environment variable at +/// resolver construction. Production-friendly; secret lives in +/// the host's environment, not in committed config. +/// * **`file`** — read from a file path at construction. Useful +/// for Kubernetes secret volumes (`/var/run/secrets/...`) or +/// similar mounted-secret patterns. +/// * **`literal`** — inline secret string. Convenient for tests +/// and dev configs; **never** for production (secret ends up +/// in committed YAML). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ClientSecretSource { + EnvVar { name: String }, + File { path: PathBuf }, + Literal { secret: String }, +} + +fn default_subject_token_type() -> String { + "urn:ietf:params:oauth:token-type:access_token".to_string() +} + +fn default_timeout_seconds() -> u64 { + 5 +} + +fn default_outbound_header() -> String { + "Authorization".to_string() +} + +impl OAuthDelegatorConfig { + /// Helper used by the constructor — exposed for tests. + pub fn timeout(&self) -> Duration { + Duration::from_secs(self.timeout_seconds) + } +} + +impl ClientSecretSource { + /// Resolve the secret at runtime, returning the raw bytes. + /// Errors as a string so the caller wraps in `PluginError::Config` + /// with context. + pub fn resolve(&self) -> Result { + match self { + Self::EnvVar { name } => std::env::var(name) + .map_err(|e| format!("env var '{name}' unavailable: {e}")), + Self::File { path } => std::fs::read_to_string(path) + .map(|s| s.trim().to_string()) + .map_err(|e| { + format!("secret file '{}' unreadable: {e}", path.display()) + }), + Self::Literal { secret } => Ok(secret.clone()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn config_deserializes_from_json() { + let raw = json!({ + "token_endpoint": "https://auth.example.com/oauth/token", + "client_id": "gateway", + "client_secret_source": { "kind": "literal", "secret": "dev-only" }, + }); + let cfg: OAuthDelegatorConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.token_endpoint, "https://auth.example.com/oauth/token"); + assert_eq!(cfg.client_id, "gateway"); + assert_eq!(cfg.timeout_seconds, 5); + assert_eq!(cfg.default_outbound_header, "Authorization"); + } + + #[test] + fn literal_secret_resolves() { + let src = ClientSecretSource::Literal { + secret: "hush".into(), + }; + assert_eq!(src.resolve().unwrap(), "hush"); + } + + #[test] + fn missing_env_var_errors() { + let src = ClientSecretSource::EnvVar { + name: "_THIS_VAR_DEFINITELY_NOT_SET_FOR_TESTS_".into(), + }; + assert!(src.resolve().is_err()); + } +} diff --git a/crates/apl-delegator-oauth/src/delegator.rs b/crates/apl-delegator-oauth/src/delegator.rs new file mode 100644 index 00000000..09508483 --- /dev/null +++ b/crates/apl-delegator-oauth/src/delegator.rs @@ -0,0 +1,474 @@ +// Location: ./crates/apl-delegator-oauth/src/delegator.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `OAuthDelegator` — `HookHandler` that performs +// RFC 8693 OAuth 2.0 Token Exchange against the configured IdP. +// +// # Flow +// +// 1. Read `payload.bearer_token()` (caller's current credential) +// and `payload.target_audience()` / `required_permissions()` / +// `route_attenuation` (the narrowing config). +// 2. Build the form-encoded body per RFC 8693: +// grant_type=urn:ietf:params:oauth:grant-type:token-exchange +// subject_token= +// subject_token_type= +// audience= +// scope= +// 3. POST to the IdP's token endpoint with HTTP Basic auth +// (client_id / client_secret). +// 4. Parse the JSON response: `{ access_token, token_type, +// expires_in, scope, issued_token_type }`. +// 5. Construct a `RawDelegatedToken` with the minted credential + +// computed expiry + effective scopes. +// 6. Return updated payload via `PluginResult::modify_payload`. +// +// # Error handling +// +// Construction errors → `Box` (`PluginError::Config`). +// Runtime errors → `PluginResult::deny(PluginViolation::new(code, +// reason))`: +// * `delegation.idp_unreachable` — network failure +// * `delegation.idp_timeout` — exceeded `timeout_seconds` +// * `delegation.idp_rejected` — IdP returned 4xx/5xx +// * `delegation.bad_response` — response not valid JSON or +// missing required fields +// * `delegation.scope_too_broad` — IdP returned a token whose +// scopes don't include all +// requested permissions + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use serde::Deserialize; +use zeroize::Zeroizing; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{DelegationPayload, TokenDelegateHook}; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use super::config::OAuthDelegatorConfig; + +/// RFC 8693 token-exchange grant type — the value of +/// `grant_type` in the form-encoded request body. +const GRANT_TYPE_TOKEN_EXCHANGE: &str = + "urn:ietf:params:oauth:grant-type:token-exchange"; + +/// Default issued-token-type RFC 8693 returns. We don't rely on it +/// for behavior — it's reported back to operators in audit logs +/// only. +const DEFAULT_ISSUED_TOKEN_TYPE: &str = + "urn:ietf:params:oauth:token-type:access_token"; + +/// OAuth-mediated `TokenDelegate` handler. +pub struct OAuthDelegator { + cfg: PluginConfig, + typed: OAuthDelegatorConfig, + /// Loaded client secret, zeroized on drop. + client_secret: Zeroizing, + /// Shared HTTP client. Pre-built so repeated invocations + /// reuse connections / TLS sessions. + http: reqwest::Client, +} + +impl std::fmt::Debug for OAuthDelegator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuthDelegator") + .field("cfg", &self.cfg.name) + .field("token_endpoint", &self.typed.token_endpoint) + .field("client_id", &self.typed.client_id) + .field("client_secret", &"") + .finish() + } +} + +impl OAuthDelegator { + /// Build a delegator from a `PluginConfig`. Reads `cfg.config` + /// into [`OAuthDelegatorConfig`], resolves the client secret, + /// constructs the shared `reqwest::Client`. + pub fn new(cfg: PluginConfig) -> Result> { + let raw = cfg.config.as_ref().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth) requires a `config:` block", + cfg.name + ), + }) + })?; + let typed: OAuthDelegatorConfig = serde_json::from_value(raw.clone()) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth) config parse failed: {e}", + cfg.name + ), + }) + })?; + + if typed.token_endpoint.trim().is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth): token_endpoint must be non-empty", + cfg.name + ), + })); + } + // Reject http:// for token_endpoint by default. The exchange + // POST sends client_id:client_secret + inbound user JWT; + // sending these over plaintext defeats the whole flow. + // `insecure_http: true` is the conscious opt-out for + // localhost docker-compose demos. + if let Err(e) = require_https(&typed.token_endpoint, typed.insecure_http) { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth): token_endpoint {e}", + cfg.name, + ), + })); + } + if typed.client_id.trim().is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth): client_id must be non-empty", + cfg.name + ), + })); + } + + let secret = typed.client_secret_source.resolve().map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth) client secret resolve failed: {e}", + cfg.name + ), + }) + })?; + + let http = reqwest::Client::builder() + .timeout(typed.timeout()) + .build() + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-delegator-oauth) HTTP client build failed: {e}", + cfg.name + ), + }) + })?; + + Ok(Self { + cfg, + typed, + client_secret: Zeroizing::new(secret), + http, + }) + } + + /// Compose the requested scope set: the target's required + /// permissions plus any extra capabilities from + /// `route_attenuation`. Returns a space-separated string per + /// OAuth conventions. + fn requested_scopes(payload: &DelegationPayload) -> String { + let mut scopes: Vec = payload.required_permissions().to_vec(); + if let Some(att) = payload.route_attenuation() { + for cap in &att.capabilities { + if !scopes.contains(cap) { + scopes.push(cap.clone()); + } + } + } + scopes.join(" ") + } +} + +/// Subset of the RFC 8693 response we care about. +#[derive(Debug, Deserialize)] +struct TokenExchangeResponse { + access_token: String, + /// Optional per RFC — defaults to `access_token` issued type. + #[serde(default)] + issued_token_type: Option, + /// Optional in RFC; many IdPs send it. + #[serde(default)] + expires_in: Option, + /// Space-separated effective scopes the IdP actually granted. + /// May be narrower than what we requested. + #[serde(default)] + scope: Option, +} + +/// Subset of the standard OAuth error response — `error` is the +/// machine-readable code (`invalid_grant`, `invalid_scope`, …). +#[derive(Debug, Deserialize)] +struct TokenErrorResponse { + error: String, + #[serde(default)] + error_description: Option, +} + +#[async_trait] +impl Plugin for OAuthDelegator { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for OAuthDelegator { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let bearer = payload.bearer_token(); + if bearer.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "DelegationPayload carried an empty bearer_token — outbound \ + caller didn't populate the credential before invoking the hook", + )); + } + let audience = payload.target_audience().unwrap_or(""); + if audience.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_request", + "target_audience missing — RFC 8693 token exchange requires \ + an audience to scope the minted credential", + )); + } + + let scope = Self::requested_scopes(payload); + + // Build the form-encoded body. RFC 8693 §2.1. + let mut form: Vec<(&str, &str)> = vec![ + ("grant_type", GRANT_TYPE_TOKEN_EXCHANGE), + ("subject_token", bearer), + ("subject_token_type", &self.typed.subject_token_type), + ("audience", audience), + ]; + if !scope.is_empty() { + form.push(("scope", &scope)); + } + + // POST to the IdP. Basic auth carries our client credentials. + let response = match self + .http + .post(&self.typed.token_endpoint) + .basic_auth(&self.typed.client_id, Some(self.client_secret.as_str())) + .form(&form) + .send() + .await + { + Ok(r) => r, + Err(e) if e.is_timeout() => { + return PluginResult::deny(PluginViolation::new( + "delegation.idp_timeout", + format!("token-exchange to {} timed out", self.typed.token_endpoint), + )); + } + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.idp_unreachable", + format!( + "token-exchange POST to {} failed: {e}", + self.typed.token_endpoint, + ), + )); + } + }; + + let status = response.status(); + if !status.is_success() { + // Try to surface the standard `error` / `error_description` + // fields from the IdP. Fall back to status code. + let body = response.text().await.unwrap_or_default(); + let (code, reason) = match serde_json::from_str::(&body) + { + Ok(err) => { + let mut reason = err.error.clone(); + if let Some(desc) = err.error_description { + reason.push_str(": "); + reason.push_str(&desc); + } + ("delegation.idp_rejected", reason) + } + Err(_) => ( + "delegation.idp_rejected", + format!("IdP returned {status}: {body}"), + ), + }; + return PluginResult::deny(PluginViolation::new(code, reason)); + } + + let parsed = match response.json::().await { + Ok(p) => p, + Err(e) => { + return PluginResult::deny(PluginViolation::new( + "delegation.bad_response", + format!("IdP response wasn't valid token-exchange JSON: {e}"), + )); + } + }; + + // Compute effective scopes. IdP's `scope` field wins (it + // reflects what was actually granted, possibly narrower + // than what we asked for); fall back to the requested set + // if the IdP didn't send one. + let effective_scopes: Vec = if let Some(s) = &parsed.scope { + s.split_whitespace().map(String::from).collect() + } else if !scope.is_empty() { + scope.split_whitespace().map(String::from).collect() + } else { + Vec::new() + }; + + // Enforce requested ⊆ effective. Without this check, a route + // that asked for `read write` and got back `read` would + // proceed as if the broader grant had succeeded — downstream + // calls would fail in policy-author-unobservable ways. We + // compare only when the IdP explicitly sent a `scope` field + // (otherwise we just used the requested set above, so the + // subset relationship is trivially true). The required + // permissions come straight off the DelegationPayload; route + // attenuation capabilities are advisory extras and not + // checked here. + if parsed.scope.is_some() { + let granted: std::collections::HashSet<&str> = + effective_scopes.iter().map(String::as_str).collect(); + let missing: Vec<&str> = payload + .required_permissions() + .iter() + .filter(|req| !granted.contains(req.as_str())) + .map(String::as_str) + .collect(); + if !missing.is_empty() { + return PluginResult::deny(PluginViolation::new( + "delegation.scope_too_broad", + format!( + "IdP granted narrower scopes than requested. \ + requested=[{}] granted=[{}] missing=[{}]", + payload.required_permissions().join(" "), + effective_scopes.join(" "), + missing.join(" "), + ), + )); + } + } + + // Compute expiry. Most IdPs send `expires_in` (seconds); + // if missing, default to 5 minutes — short enough that a + // misconfigured-but-no-expiry IdP doesn't mint long-lived + // tokens by accident. + let ttl_secs = parsed.expires_in.unwrap_or(300); + // Route attenuation may shorten further. + let ttl_secs = if let Some(att) = payload.route_attenuation() { + if let Some(hint) = att.ttl_seconds { + ttl_secs.min(hint as i64) + } else { + ttl_secs + } + } else { + ttl_secs + }; + let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs); + + let token = RawDelegatedToken::new( + parsed.access_token, + self.typed.default_outbound_header.clone(), + audience.to_string(), + effective_scopes, + expires_at, + ); + + let mut updated = payload.clone(); + updated.delegated_token = Some(token); + updated.delegation_mode = Some(DelegationMode::OnBehalfOfUser); + updated.minted_at = Some(Utc::now()); + if let Some(issued) = parsed.issued_token_type { + updated.metadata.insert( + "issued_token_type".into(), + serde_json::Value::String(issued), + ); + } else { + updated.metadata.insert( + "issued_token_type".into(), + serde_json::Value::String(DEFAULT_ISSUED_TOKEN_TYPE.into()), + ); + } + + PluginResult::modify_payload(updated) + } +} + +// Silence unused-import warning when only a subset of these is +// reached in any given config path. Kept as a single place so the +// crate's surface is visible at a glance. +#[allow(dead_code)] +fn _force_link(_: Arc<()>) {} + +/// Reject `http://` for endpoints that carry credentials. Allows +/// `https://` unconditionally and `http://` only when the operator +/// explicitly set `insecure_http: true`. Empty / un-parseable URLs +/// are returned as-is to whatever validator already exists upstream +/// — this helper only owns the scheme check. +/// +/// Returns a short fragment ("must use https://…") that the caller +/// prepends with the field name + plugin name for the full error +/// message. +fn require_https(url: &str, insecure_http: bool) -> Result<(), String> { + let lowered = url.trim_start().to_ascii_lowercase(); + if lowered.starts_with("https://") { + return Ok(()); + } + if lowered.starts_with("http://") { + if insecure_http { + return Ok(()); + } + return Err(format!( + "must use https:// (got '{url}'). Set `insecure_http: true` \ + to allow plaintext for localhost/dev only — never production." + )); + } + // Anything else (missing scheme, bad scheme): defer to the + // upstream URL parser. We're not the URL validator, just the + // scheme gate. + Ok(()) +} + +#[cfg(test)] +mod scheme_tests { + use super::require_https; + + #[test] + fn https_always_ok() { + assert!(require_https("https://idp.example/oauth/token", false).is_ok()); + assert!(require_https("HTTPS://IDP.EXAMPLE/", false).is_ok()); + } + + #[test] + fn http_default_rejected() { + let err = require_https("http://localhost:8081/oauth/token", false).unwrap_err(); + assert!(err.contains("must use https"), "{}", err); + assert!(err.contains("insecure_http"), "mentions opt-out: {}", err); + } + + #[test] + fn http_with_explicit_opt_in_allowed() { + assert!(require_https("http://localhost:8081/oauth/token", true).is_ok()); + } + + #[test] + fn http_with_leading_whitespace_still_rejected() { + // A trailing newline or leading whitespace from sloppy YAML + // shouldn't smuggle a plaintext URL past the gate. + let err = require_https(" http://idp/", false).unwrap_err(); + assert!(err.contains("must use https")); + } +} diff --git a/crates/apl-delegator-oauth/src/factory.rs b/crates/apl-delegator-oauth/src/factory.rs new file mode 100644 index 00000000..b6a6c167 --- /dev/null +++ b/crates/apl-delegator-oauth/src/factory.rs @@ -0,0 +1,59 @@ +// Location: ./crates/apl-delegator-oauth/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `PluginFactory` impl for the OAuth 2.0 (RFC 8693) token-exchange +// delegator. Lives here (alongside the delegator) so every host — +// Praxis filter, Envoy bridge, CLI runner, test harness — wires it +// up the same way. +// +// Operators declare it in CPEX YAML as: +// +// plugins: +// - name: workday-oauth +// kind: delegator/oauth +// hooks: [token.delegate] +// config: +// token_endpoint: https://idp.example.com/token +// client_id: praxis-cpex +// client_secret_source: { kind: env, var: OAUTH_CLIENT_SECRET } +// +// The `kind: delegator/oauth` string is part of this crate's public +// API. Hosts call +// `mgr.register_factory("delegator/oauth", Box::new(OAuthDelegatorFactory))` +// before `load_config_yaml`. + +use std::sync::Arc; + +use cpex_core::{ + delegation::{TokenDelegateHook, HOOK_TOKEN_DELEGATE}, + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + plugin::PluginConfig, +}; + +use crate::OAuthDelegator; + +/// The plugin `kind:` string operators write in CPEX YAML to declare +/// an OAuth RFC 8693 token-exchange delegator. +pub const KIND: &str = "delegator/oauth"; + +/// Factory for `kind: delegator/oauth` plugins. Instantiates an +/// `OAuthDelegator` from the `config:` block and registers it on the +/// `token.delegate` hook. +pub struct OAuthDelegatorFactory; + +impl PluginFactory for OAuthDelegatorFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let delegator = Arc::new(OAuthDelegator::new(config.clone())?); + let handler = Arc::new(TypedHandlerAdapter::::new( + Arc::clone(&delegator), + )); + Ok(PluginInstance { + plugin: delegator, + handlers: vec![(HOOK_TOKEN_DELEGATE, handler)], + }) + } +} diff --git a/crates/apl-delegator-oauth/src/lib.rs b/crates/apl-delegator-oauth/src/lib.rs new file mode 100644 index 00000000..4e81c1e1 --- /dev/null +++ b/crates/apl-delegator-oauth/src/lib.rs @@ -0,0 +1,29 @@ +// Location: ./crates/apl-delegator-oauth/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-delegator-oauth — `TokenDelegateHandler` backed by RFC 8693 +// OAuth 2.0 Token Exchange. +// +// The host registers this handler against `token.delegate`; outbound +// forwarding plugins invoke `mgr.invoke_named::(...)` +// with a `DelegationPayload` (caller's bearer token + target +// audience + required scopes); this handler POSTs to the configured +// OAuth server's token endpoint with `grant_type=urn:ietf:params: +// oauth:grant-type:token-exchange` and the appropriate +// `subject_token` / `audience` / `scope` parameters; the response's +// `access_token` becomes the `RawDelegatedToken` the framework +// stashes under `Extensions.raw_credentials.delegated_tokens`. +// +// Sub-step A scope: data shapes + module structure only. Actual +// HTTP exchange logic in sub-step B; mock-IdP integration tests in +// sub-step C. + +pub mod config; +pub mod delegator; +pub mod factory; + +pub use config::{ClientSecretSource, OAuthDelegatorConfig}; +pub use delegator::OAuthDelegator; +pub use factory::{OAuthDelegatorFactory, KIND}; diff --git a/crates/apl-delegator-oauth/tests/oauth_e2e.rs b/crates/apl-delegator-oauth/tests/oauth_e2e.rs new file mode 100644 index 00000000..ff10351b --- /dev/null +++ b/crates/apl-delegator-oauth/tests/oauth_e2e.rs @@ -0,0 +1,382 @@ +// Location: ./crates/apl-delegator-oauth/tests/oauth_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end tests for `OAuthDelegator` against a `mockito`-backed +// fake IdP. Exercises the full handler path: +// `mgr.invoke_named::(...)` → delegator builds +// RFC 8693 form body → POSTs to mock IdP → mock returns response +// → delegator translates into a `RawDelegatedToken` → host +// extracts via `from_pipeline_result`. +// +// Scenarios: +// * happy path — minted token populated with audience + scopes + expiry +// * IdP returns 400 with `invalid_grant` — surfaces `delegation.idp_rejected` +// * IdP unreachable — surfaces `delegation.idp_unreachable` +// * Request body shape — mockito's matcher verifies we send the +// correct RFC 8693 fields + +use std::sync::Arc; + +use cpex_core::delegation::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, + HOOK_TOKEN_DELEGATE, +}; +use cpex_core::extensions::raw_credentials::DelegationMode; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use apl_delegator_oauth::OAuthDelegator; + +use mockito::{Matcher, Server}; +use serde_json::json; + +// ===================================================================== +// Fixtures +// ===================================================================== + +fn plugin_config(token_endpoint: &str) -> PluginConfig { + PluginConfig { + name: "oauth-delegator".into(), + kind: "test".into(), + hooks: vec![HOOK_TOKEN_DELEGATE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(json!({ + "token_endpoint": token_endpoint, + "client_id": "gateway-client", + "client_secret_source": { + "kind": "literal", + "secret": "test-secret", + }, + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + "timeout_seconds": 2, + "default_outbound_header": "Authorization", + // wiremock binds to http://127.0.0.1 — opt in to plaintext + // for the test. Production deployments must omit this. + "insecure_http": true, + })), + ..Default::default() + } +} + +fn build_payload(target: &str, audience: &str, scopes: &[&str]) -> DelegationPayload { + DelegationPayload::new("caller-bearer-token-bytes", target) + .with_target_type(TargetType::Tool) + .with_target_audience(audience) + .with_required_permissions(scopes.iter().map(|s| s.to_string()).collect()) + .with_auth_enforced_by(AuthEnforcedBy::Target) + .with_route_attenuation(AttenuationConfig { + capabilities: vec!["audit".into()], + resource_template: None, + actions: Vec::new(), + ttl_seconds: Some(120), + }) +} + +async fn build_manager(token_endpoint: &str) -> Arc { + let cfg = plugin_config(token_endpoint); + let delegator = OAuthDelegator::new(cfg.clone()).expect("delegator constructs"); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::new(delegator), + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +async fn invoke( + mgr: &Arc, + payload: DelegationPayload, +) -> cpex_core::executor::PipelineResult { + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + payload, + Extensions::default(), + None, + ) + .await; + result +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Happy path: mock IdP responds with a fresh access_token; the +/// delegator translates it into a `RawDelegatedToken` populated +/// with the requested audience, the effective scopes, and an +/// expiry derived from `expires_in`. +#[tokio::test] +async fn happy_path_mints_delegated_token() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .match_header("content-type", "application/x-www-form-urlencoded") + // Expect the form fields RFC 8693 requires. + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded( + "grant_type".into(), + "urn:ietf:params:oauth:grant-type:token-exchange".into(), + ), + Matcher::UrlEncoded( + "subject_token".into(), + "caller-bearer-token-bytes".into(), + ), + Matcher::UrlEncoded( + "audience".into(), + "https://hr.example.com".into(), + ), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "access_token": "minted-downstream-jwt", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "expires_in": 300, + "scope": "read:compensation audit", + }) + .to_string(), + ) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "happy path should mint a token: violation = {:?}", + result.violation, + ); + + let final_payload = DelegationPayload::from_pipeline_result(&result) + .expect("delegation payload should be present"); + let token = final_payload + .delegated_token + .as_ref() + .expect("delegated_token populated"); + + assert_eq!(&*token.token, "minted-downstream-jwt"); + assert_eq!(token.audience, "https://hr.example.com"); + assert_eq!(token.outbound_header, "Authorization"); + // Effective scopes come from the IdP's `scope` field. + assert!(token.scopes.contains(&"read:compensation".to_string())); + assert!(token.scopes.contains(&"audit".to_string())); + + // Mode is OnBehalfOfUser by default for RFC 8693 exchange. + assert!(matches!( + final_payload.delegation_mode, + Some(DelegationMode::OnBehalfOfUser), + )); + + // TTL respects the route hint (120s) — IdP's expires_in was 300, + // but the route asked to cap at 120, so effective is 120. + let ttl_left = (token.expires_at - chrono::Utc::now()).num_seconds(); + assert!( + ttl_left <= 120 && ttl_left > 100, + "ttl should reflect min(idp_ttl, route_hint); got {ttl_left}s", + ); + + mock.assert_async().await; +} + +/// IdP returns a 400 with the standard `error` / `error_description` +/// shape — delegator surfaces `delegation.idp_rejected` carrying the +/// IdP's machine-readable code. +#[tokio::test] +async fn idp_rejection_surfaces_error_code() { + let mut server = Server::new_async().await; + server + .mock("POST", "/oauth/token") + .with_status(400) + .with_header("content-type", "application/json") + .with_body( + json!({ + "error": "invalid_grant", + "error_description": "subject_token is not active", + }) + .to_string(), + ) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.idp_rejected"); + assert!( + violation.reason.contains("invalid_grant"), + "reason should include IdP's error code; got: {}", + violation.reason, + ); + assert!( + violation.reason.contains("not active"), + "reason should include the error_description; got: {}", + violation.reason, + ); +} + +/// IdP unreachable (mockito server stopped) — delegator surfaces +/// `delegation.idp_unreachable` rather than panicking. +#[tokio::test] +async fn idp_unreachable_surfaces_violation() { + // Use a localhost URL that should be unreachable (no listener + // on that port). The `127.0.0.1:1` port-1 trick: port 1 isn't + // bound by typical systems and connection refusal is fast. + let mgr = build_manager("http://127.0.0.1:1/oauth/token").await; + let payload = build_payload( + "tool", + "https://downstream.example.com", + &["read"], + ); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let violation = result.violation.expect("rejection should surface"); + // Either `idp_unreachable` (connection refused) or `idp_timeout` + // (if the OS decides to slow-fail) — both are valid outcomes + // for "IdP isn't there." The test accepts either. + assert!( + violation.code == "delegation.idp_unreachable" + || violation.code == "delegation.idp_timeout", + "expected idp_unreachable or idp_timeout; got {}", + violation.code, + ); +} + +/// Empty bearer token — fails fast at the handler entry before +/// touching the network. Verifies the input-validation path. +#[tokio::test] +async fn empty_bearer_token_rejects_without_network() { + let mgr = build_manager("http://this-must-not-be-called/oauth/token").await; + let payload = DelegationPayload::new("", "tool") + .with_target_audience("https://downstream.example.com"); + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.bad_request"); + assert!(violation.reason.contains("empty bearer_token")); +} + +/// Missing target audience — fails fast (RFC 8693 requires +/// `audience` for downstream scoping). +#[tokio::test] +async fn missing_audience_rejects_without_network() { + let mgr = build_manager("http://this-must-not-be-called/oauth/token").await; + let payload = DelegationPayload::new("some-token", "tool"); // no audience + + let result = invoke(&mgr, payload).await; + assert!(!result.continue_processing); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.bad_request"); + assert!(violation.reason.contains("target_audience")); +} + +/// IdP grants narrower scopes than requested — delegator emits the +/// documented `delegation.scope_too_broad` code rather than silently +/// proceeding. Without this check, a route that requested +/// `read+write` and got back only `read` would mint a token the +/// downstream call can't actually use, leaving the policy author +/// with no observable signal about *why* the call failed downstream. +#[tokio::test] +async fn idp_narrower_scope_surfaces_scope_too_broad() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "access_token": "narrower-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "expires_in": 300, + // Asked for both, got only `read`. + "scope": "read", + }) + .to_string(), + ) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "tool", + "https://downstream.example.com", + &["read", "write"], + ); + + let result = invoke(&mgr, payload).await; + assert!( + !result.continue_processing, + "narrower IdP grant must NOT silently succeed", + ); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.scope_too_broad"); + assert!( + violation.reason.contains("write"), + "reason should name the missing scope: {}", + violation.reason, + ); + + mock.assert_async().await; +} + +/// Sanity check: when the IdP grants exactly the requested set, the +/// scope check passes. Pins the "no false positive" half of the +/// scope_too_broad behaviour. +#[tokio::test] +async fn idp_exact_scope_match_succeeds() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "access_token": "ok-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "expires_in": 300, + "scope": "read write", + }) + .to_string(), + ) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "tool", + "https://downstream.example.com", + &["read", "write"], + ); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "exact scope match should mint a token; violation = {:?}", + result.violation, + ); + mock.assert_async().await; +} diff --git a/crates/apl-identity-jwt/Cargo.toml b/crates/apl-identity-jwt/Cargo.toml new file mode 100644 index 00000000..65dcf09b --- /dev/null +++ b/crates/apl-identity-jwt/Cargo.toml @@ -0,0 +1,92 @@ +# Location: ./crates/apl-identity-jwt/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-identity-jwt — JWT-based `IdentityResolveHandler`. +# +# Validates inbound JWTs against configured trusted issuers +# (signature + exp + aud + iss claims) and maps the validated claims +# into `SubjectExtension` / `ClientExtension` via a configurable +# claim-mapper. The raw token is stashed in +# `RawCredentialsExtension.inbound_tokens` for forwarding plugins +# downstream. +# +# # Why this exists alongside `apl-cedarling` +# +# Cedarling's JWT validation is bundled with Cedar policy +# evaluation — it doesn't expose validated identity as a separate +# data product. For deployments that want JWT validation without +# (or before) the Cedar policy step, this crate fills the gap. +# Lightweight (~5-15 transitive deps) vs Cedarling (~200). +# +# # Default-members +# +# This crate IS in the workspace's default-members — the dep tree +# is small enough that it doesn't slow down default builds. Compare +# to `apl-cedarling`, which is excluded. + +[package] +name = "apl-identity-jwt" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +cpex-core = { path = "../cpex-core" } + +# `jsonwebtoken` is the de facto JWT library for Rust. ~5 transitive +# deps (ring, base64, serde, pem). Supports RS256/RS384/RS512, +# ES256/ES384, EdDSA, HS256/HS384/HS512. Default features include +# `use_pem` (load DecodingKey from PEM strings) which we want. +jsonwebtoken = "9" + +# `base64` for the peek-at-iss helper (split + URL_SAFE_NO_PAD decode +# of the middle JWT segment). Pinned to 0.22 to match what +# jsonwebtoken 9 pulls — Cargo dedups to a single version. +base64 = "0.22" + +# `chrono` for the `resolved_at` timestamp on IdentityPayload. Comes +# in transitively via cpex-core already; redeclaring keeps the +# direct-dep relationship visible. +chrono = { workspace = true } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +# Async HTTP — used by `DecodingKeySource::build_async()` to fetch +# IdP JWKS during `Plugin::initialize()`. We default the rustls-tls +# backend (no OpenSSL system dep) and turn off any features we don't +# use to keep the dep tree lean. apl-delegator-oauth pulls reqwest +# too, so cargo dedups to one copy. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# `futures::join_all` so multiple resolvers' JWKS endpoints fetch +# concurrently at initialize time rather than one-at-a-time. +futures = { workspace = true } + +# `tokio::spawn` + `tokio::time::interval` for the background JWKS +# refresh tasks introduced in Slice B. The runtime is already in +# the workspace dep tree via cpex-core / apl-cpex, so this just +# makes the existing types directly nameable here. We only need +# the runtime + time features at runtime; full "macros / rt / rt-multi-thread" +# was test-only previously. +tokio = { workspace = true, features = ["rt", "time"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +# Mock JWKS endpoint for the async `JwksUrl` resolution tests. +mockito = "1" +# RSA keypair + JWK fixtures for tests. `rsa` generates keypairs; +# `pkcs8` encodes them in the PEM format jsonwebtoken accepts. +rsa = { version = "0.9", features = ["pem"] } +# rsa 0.9's `RsaPrivateKey::new(&mut rng, bits)` takes an rng +# implementing `rand_core::CryptoRngCore` — `rand::thread_rng()` +# satisfies that. Test-only dep. +rand = "0.8" diff --git a/crates/apl-identity-jwt/src/claim_map.rs b/crates/apl-identity-jwt/src/claim_map.rs new file mode 100644 index 00000000..f1fbd5e2 --- /dev/null +++ b/crates/apl-identity-jwt/src/claim_map.rs @@ -0,0 +1,401 @@ +// Location: ./crates/apl-identity-jwt/src/claim_map.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `ClaimMapper` — converts validated JWT claims into a populated +// `SubjectExtension`. +// +// Different IdPs use different claim shapes: +// +// * Keycloak — `realm_access.roles` (nested array), `email`, +// `preferred_username`, custom `groups` array +// * Auth0 — flat `permissions` array, `https://my-app/roles` +// (namespaced custom claims), `email` +// * Cognito — `cognito:groups`, `cognito:username`, +// `cognito:roles` +// * Standard OIDC — `sub`, `email`, `name`, `groups`, … +// +// `StandardClaimMap` covers the OIDC-standard shape; deployments +// with bespoke IdPs implement `ClaimMapper` themselves and inject +// at resolver construction. + +use std::collections::HashMap; + +use serde_json::Value; + +use cpex_core::extensions::{ClientExtension, SubjectExtension, WorkloadIdentity}; + +/// Convert a validated JWT's claim map into the typed identity slot +/// for the resolver's configured role. +/// +/// Implementations supply one method per role they understand: +/// +/// * [`map_subject`] — `sub` plus subject-shaped fields, for +/// `TokenRole::User`. +/// * [`map_client`] — `client_id` plus client-shaped fields, for +/// `TokenRole::Client`. +/// * [`map_workload`] — SPIFFE-style identity, for `TokenRole::Workload`. +/// +/// Each defaults to `None` so existing custom mappers stay valid — +/// they get implicit "this mapper doesn't know how to do that role," +/// which the resolver surfaces as `auth.mapping_failed` when an +/// operator wires a role the mapper can't fill. +/// +/// `Debug` is a supertrait so structs holding `Arc` +/// (notably `JwtIdentityResolver`) can themselves derive `Debug`. +/// +/// [`map_subject`]: ClaimMapper::map_subject +/// [`map_client`]: ClaimMapper::map_client +/// [`map_workload`]: ClaimMapper::map_workload +pub trait ClaimMapper: std::fmt::Debug + Send + Sync { + /// Map JWT claims into a `SubjectExtension` (for `role: user`). + fn map_subject(&self, claims: &HashMap) -> Option { + let _ = claims; + None + } + + /// Map JWT claims into a `ClientExtension` (for `role: client`). + /// Default returns `None` — implementations that handle client + /// tokens override this. + fn map_client(&self, claims: &HashMap) -> Option { + let _ = claims; + None + } + + /// Map JWT claims into a `WorkloadIdentity` (for `role: workload`). + /// Default returns `None` — implementations that handle SPIFFE / + /// SPIFFE-JWT-SVID tokens override this. + fn map_workload(&self, claims: &HashMap) -> Option { + let _ = claims; + None + } +} + +/// Type alias matching what `jsonwebtoken::decode::(...)` +/// produces — a JSON object's key/value pairs. +pub type ClaimMap = HashMap; + +/// Default `ClaimMapper` covering the OIDC-standard claim shape: +/// +/// * `sub` → `subject.id` (required) +/// * `roles` → `subject.roles` (string array) +/// * `permissions` / `scope` → `subject.permissions` (array or +/// space-separated string) +/// * `groups` / `teams` → `subject.teams` (string array) +/// * Every other claim → `subject.claims.` (stringified) +/// +/// Implementations with non-standard IdPs (Keycloak's nested +/// `realm_access.roles`, AWS Cognito's `cognito:*` prefixed claims) +/// write their own `ClaimMapper`; this struct is for the common +/// vanilla-OIDC case. +#[derive(Debug, Clone, Default)] +pub struct StandardClaimMap; + +impl ClaimMapper for StandardClaimMap { + fn map_client(&self, claims: &ClaimMap) -> Option { + // `client_id` is required for ClientExtension — it's the anchor + // identifier policy authors gate on. Falls back to `azp` + // (authorized party, OIDC §2 for the "client_id of the party + // to which the token was issued") which Keycloak and several + // OPs send in place of `client_id`. + let client_id = claims + .get("client_id") + .or_else(|| claims.get("azp")) + .and_then(Value::as_str)? + .to_string(); + + let mut client = ClientExtension { + client_id, + ..Default::default() + }; + + if let Some(name) = claims.get("client_name").and_then(Value::as_str) { + client.client_name = Some(name.to_string()); + } + + // Scopes — array OR space-separated string. + if let Some(arr) = claims.get("authorized_scopes").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + client.authorized_scopes.push(s.to_string()); + } + } + } else if let Some(s) = claims.get("scope").and_then(Value::as_str) { + for scope in s.split_whitespace() { + if !scope.is_empty() { + client.authorized_scopes.push(scope.to_string()); + } + } + } + + // Audiences — single string or array (RFC 7519 §4.1.3). + match claims.get("aud") { + Some(Value::String(s)) => client.authorized_audiences.push(s.clone()), + Some(Value::Array(arr)) => { + for v in arr { + if let Some(s) = v.as_str() { + client.authorized_audiences.push(s.to_string()); + } + } + } + _ => {} + } + + // Platform-native roles. + if let Some(arr) = claims.get("roles").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + client.roles.push(s.to_string()); + } + } + } + + // Remaining claims — keyed by name with full Value preserved + // (ClientExtension.claims is HashMap, + // unlike SubjectExtension.claims which stringifies). + const RESERVED: &[&str] = &[ + "client_id", + "azp", + "client_name", + "authorized_scopes", + "scope", + "aud", + "roles", + "iss", + "exp", + "nbf", + "iat", + "jti", + "sub", + ]; + for (k, v) in claims { + if RESERVED.contains(&k.as_str()) { + continue; + } + client.claims.insert(k.clone(), v.clone()); + } + + Some(client) + } + + fn map_workload(&self, claims: &ClaimMap) -> Option { + // SPIFFE JWT-SVID convention: the SPIFFE ID lives in `sub` + // (per the SPIFFE JWT-SVID spec). We look there first, then + // fall back to an explicit `spiffe_id` claim for IdPs that + // surface it separately. + let spiffe_id = claims + .get("sub") + .and_then(Value::as_str) + .filter(|s| s.starts_with("spiffe://")) + .or_else(|| claims.get("spiffe_id").and_then(Value::as_str)) + .map(str::to_string)?; + + // Trust domain — pull from the SPIFFE-ID host part. + let trust_domain = spiffe_id + .strip_prefix("spiffe://") + .and_then(|rest| rest.split('/').next()) + .map(str::to_string); + + Some(WorkloadIdentity { + spiffe_id: Some(spiffe_id), + trust_domain, + attested_at: None, + attestor: Some("jwt".to_string()), + ..Default::default() + }) + } + + fn map_subject(&self, claims: &ClaimMap) -> Option { + // `sub` is required — RFC 7519 §4.1.2 makes it optional in + // the spec but it's effectively mandatory for identity flows. + let sub = claims.get("sub").and_then(Value::as_str)?.to_string(); + + let mut subject = SubjectExtension { + id: Some(sub), + ..Default::default() + }; + + // `roles` — array of strings. + if let Some(arr) = claims.get("roles").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + subject.roles.insert(s.to_string()); + } + } + } + + // `permissions` (array) OR `scope` (space-separated string, + // OAuth-style). Either populates `subject.permissions`. + if let Some(arr) = claims.get("permissions").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + subject.permissions.insert(s.to_string()); + } + } + } else if let Some(s) = claims.get("scope").and_then(Value::as_str) { + for scope in s.split_whitespace() { + if !scope.is_empty() { + subject.permissions.insert(scope.to_string()); + } + } + } + + // `teams` (explicit) preferred; fall back to `groups` (OIDC + // conventional name for the same concept). + if let Some(arr) = claims.get("teams").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + subject.teams.insert(s.to_string()); + } + } + } else if let Some(arr) = claims.get("groups").and_then(Value::as_array) { + for v in arr { + if let Some(s) = v.as_str() { + subject.teams.insert(s.to_string()); + } + } + } + + // Every other claim → `subject.claims.`. + // SubjectExtension.claims is HashMap, so + // non-string values get stringified (JSON-serialized). The + // reserved-claim set is the ones we already mapped to + // structured fields, plus the JWT standard registered + // claims (iss/aud/exp/nbf/iat/jti) which aren't useful as + // policy-visible claims. + const RESERVED: &[&str] = &[ + "sub", + "roles", + "permissions", + "scope", + "teams", + "groups", + "iss", + "aud", + "exp", + "nbf", + "iat", + "jti", + ]; + for (k, v) in claims { + if RESERVED.contains(&k.as_str()) { + continue; + } + let stringified = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + subject.claims.insert(k.clone(), stringified); + } + + Some(subject) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_claims(json: Value) -> ClaimMap { + json.as_object().unwrap().clone().into_iter().collect() + } + + #[test] + fn sub_becomes_subject_id() { + let claims = make_claims(json!({"sub": "alice@corp.com"})); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); + } + + #[test] + fn missing_sub_returns_none() { + // No `sub` claim → mapper rejects. Caller will surface + // this as `auth.mapping_failed`. + let claims = make_claims(json!({"email": "alice@corp.com"})); + assert!(StandardClaimMap.map_subject(&claims).is_none()); + } + + #[test] + fn roles_array_becomes_subject_roles() { + let claims = make_claims(json!({ + "sub": "alice", + "roles": ["hr", "admin"], + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.roles.contains("hr")); + assert!(subject.roles.contains("admin")); + } + + #[test] + fn scope_string_splits_into_permissions() { + // OAuth-style space-separated scope claim — `scope: "read write"`. + let claims = make_claims(json!({ + "sub": "alice", + "scope": "read write delete", + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.permissions.contains("read")); + assert!(subject.permissions.contains("write")); + assert!(subject.permissions.contains("delete")); + } + + #[test] + fn permissions_array_preferred_over_scope() { + // If both are present, `permissions` (array) wins. Most + // modern IdPs send arrays; OAuth-1-era `scope` is a fallback. + let claims = make_claims(json!({ + "sub": "alice", + "permissions": ["call_tool", "list_tools"], + "scope": "read write", + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.permissions.contains("call_tool")); + // `scope` ignored when `permissions` is present. + assert!(!subject.permissions.contains("read")); + } + + #[test] + fn groups_fallback_when_teams_absent() { + let claims = make_claims(json!({ + "sub": "alice", + "groups": ["engineering", "platform"], + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.teams.contains("engineering")); + assert!(subject.teams.contains("platform")); + } + + #[test] + fn teams_preferred_over_groups() { + let claims = make_claims(json!({ + "sub": "alice", + "teams": ["explicit-team"], + "groups": ["fallback-group"], + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert!(subject.teams.contains("explicit-team")); + assert!(!subject.teams.contains("fallback-group")); + } + + #[test] + fn unmapped_claims_land_in_subject_claims_map() { + let claims = make_claims(json!({ + "sub": "alice", + "email": "alice@corp.com", + "preferred_username": "alice", + "iat": 1700000000, // reserved, should be skipped + })); + let subject = StandardClaimMap.map_subject(&claims).unwrap(); + assert_eq!(subject.claims.get("email"), Some(&"alice@corp.com".to_string())); + assert_eq!( + subject.claims.get("preferred_username"), + Some(&"alice".to_string()), + ); + // Reserved JWT claims aren't propagated as policy-visible + // subject claims. + assert!(!subject.claims.contains_key("iat")); + assert!(!subject.claims.contains_key("sub")); + } +} diff --git a/crates/apl-identity-jwt/src/config.rs b/crates/apl-identity-jwt/src/config.rs new file mode 100644 index 00000000..37a70da7 --- /dev/null +++ b/crates/apl-identity-jwt/src/config.rs @@ -0,0 +1,511 @@ +// Location: ./crates/apl-identity-jwt/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Typed configuration for `JwtIdentityResolver`. Deserializes from +// the plugin's `PluginConfig.config: Option` field; the +// resolver's constructor reads this and builds the runtime state +// (DecodingKey instances, claim mapper selection). +// +// Serializable intermediate representations (`DecodingKeySource`) +// stand in for non-serializable runtime types (`DecodingKey`). The +// build step on each type turns the config representation into the +// runtime form. + +use std::path::PathBuf; + +use cpex_core::extensions::raw_credentials::TokenRole; +use jsonwebtoken::{Algorithm, DecodingKey}; +use serde::{Deserialize, Serialize}; + +use super::trusted_issuer::{KeyStore, TrustedIssuer}; + +/// Top-level plugin config — what operators write under +/// `plugins[].config:` in unified-config YAML. +/// +/// One instance of this plugin handles ONE inbound credential +/// (one header, one role). Wire multiple instances if a deployment +/// expects multiple inbound tokens — e.g. user JWT in +/// `X-User-Token`, OAuth client token in `Authorization`, and a +/// SPIFFE JWT-SVID in `X-Workload-Token`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwtIdentityResolverConfig { + /// One or more trusted issuers. At least one required. + pub trusted_issuers: Vec, + + /// Which identity slot this resolver fills. Determines: + /// + /// * Which `TokenRole` key the raw token gets stashed under in + /// `RawCredentialsExtension.inbound_tokens`. + /// * Which `SecurityExtension` slot the mapped identity writes + /// into — `User` → `security.subject`, `Client` → + /// `security.client`, `Workload` → `security.caller_workload`. + /// + /// Default `User` keeps single-resolver deployments backwards- + /// compatible. Custom roles aren't supported yet — the resolver + /// errors at construction. + #[serde(default = "default_role")] + pub role: TokenRole, + + /// HTTP header name this resolver reads its token from + /// (e.g. `"Authorization"`, `"X-User-Token"`). The `Bearer ` + /// prefix is stripped if present. Recorded on + /// `RawInboundToken.source_header` so forwarding plugins can + /// re-attach (or strip) the credential under the same name. + /// Default `Authorization` matches the most common case. + #[serde(default = "default_header")] + pub header: String, + + /// Which claim mapper to use. `"standard"` is the OIDC default; + /// future named mappers (e.g., `"keycloak"`, `"cognito"`) plug + /// in via the registry pattern in `resolver.rs`. Omitted → + /// `StandardClaimMap`. + #[serde(default)] + pub claim_mapper: Option, +} + +fn default_role() -> TokenRole { + TokenRole::User +} + +/// Default JWKS refresh interval — 10 minutes. High enough that a +/// fleet of gateways isn't constantly hammering the IdP; low enough +/// that a routine key rotation propagates within a normal change +/// window. Operators with stricter or laxer needs override per +/// `JwksUrl` via the `refresh_secs` field. +fn default_refresh_secs() -> u64 { + 600 +} + +fn default_header() -> String { + "Authorization".to_string() +} + +/// One issuer's config — issuer URL, audiences, decoding key +/// source, accepted algorithms. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrustedIssuerConfig { + /// Expected `iss` claim value. + pub issuer: String, + + /// Expected audience(s). Empty list disables `aud` validation. + #[serde(default)] + pub audiences: Vec, + + /// Algorithms accepted for signature verification (e.g., + /// `RS256`, `ES256`). At least one required. + pub algorithms: Vec, + + /// Source of the decoding key. See [`DecodingKeySource`]. + pub decoding_key: DecodingKeySource, + + /// Clock-skew tolerance for `exp` / `nbf` validation, in + /// seconds. `0` (default) means "use resolver default" — the + /// constructor applies a sensible value (currently 60s). + #[serde(default)] + pub leeway_seconds: u64, +} + +/// Where the JWT signing key material comes from. Serializable +/// intermediate; the resolver builds a runtime `DecodingKey` from +/// it at construction time. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DecodingKeySource { + /// Inline PEM-encoded public key (RSA / EC). Useful for tests + /// and dev configs; production deployments usually prefer + /// `pem_file` so keys don't appear in checked-in configs. + Pem { pem: String }, + + /// Path to a PEM file. Read at construction time. Path is + /// resolved relative to the host's working directory unless + /// absolute. + PemFile { path: PathBuf }, + + /// Inline JWK (JSON Web Key) — full JWK structure as JSON. + Jwk { jwk: serde_json::Value }, + + /// OIDC JWKS endpoint — the standard way to wire to a real IdP + /// (Keycloak / Auth0 / Cognito / Okta / Authentik …). Fetched + /// at plugin `initialize()` and re-fetched every `refresh_secs` + /// thereafter so IdP key rolls don't require a gateway + /// restart. Each fetched signature-use key is indexed by its + /// `kid` so the verify path can select the right one per + /// token (overlapping rotation windows work). + /// + /// **`insecure_http`** defaults to `false` — `build_async` + /// rejects `http://` URLs. With JWKS over plaintext, anyone on + /// the network path can swap the key material and forge JWTs + /// the gateway accepts. Set to `true` only for `http://localhost` + /// docker-compose development; production must always use https. + /// + /// **`refresh_secs`** controls how often the background + /// refresh task re-fetches the JWKS. Default 600 (10 minutes) + /// — high enough that a fleet of gateways doesn't hammer the + /// IdP, low enough that a routine key roll propagates within + /// the same business hour. A failed refresh logs a warning + /// and keeps the previous KeyStore — verification continues + /// to work as long as one of the previously-fetched keys + /// matches the inbound token's `kid`. + JwksUrl { + url: String, + #[serde(default)] + insecure_http: bool, + #[serde(default = "default_refresh_secs")] + refresh_secs: u64, + }, + + /// Symmetric HMAC secret (HS256 / HS384 / HS512 only). Not + /// recommended for production; signature verifiers need the + /// same secret, which makes key distribution painful. + Secret { secret: String }, +} + +impl DecodingKeySource { + /// Whether this source needs network I/O to resolve. Used by + /// `JwtIdentityResolver` to decide between eager (sync) build at + /// `new()` and deferred (async) build at `Plugin::initialize()`. + pub fn needs_async(&self) -> bool { + matches!(self, Self::JwksUrl { .. }) + } + + /// How often the background refresh task should re-fetch this + /// source. `Some(_)` for `JwksUrl` (the only refreshable + /// variant), `None` for inline sources whose key material is + /// static for the resolver's lifetime. + pub fn refresh_interval(&self) -> Option { + match self { + Self::JwksUrl { refresh_secs, .. } => { + Some(std::time::Duration::from_secs(*refresh_secs)) + } + _ => None, + } + } + + /// Synchronously turn the source into a [`KeyStore`]. Works for + /// inline / on-disk sources; **errors for `JwksUrl`** — use + /// [`build_async`] for those. Returns a string error so callers + /// can wrap into `PluginError::Config` with context. + /// + /// Inline sources have no `kid` context, so the resulting store + /// has a single `fallback` entry usable for any token whose + /// header omits `kid`. Tokens that DO carry a `kid` against an + /// inline source resolve to `auth.unknown_kid` at verify time — + /// the JWKS spec is the source of truth for which kids exist. + /// + /// [`build_async`]: Self::build_async + pub fn build(&self) -> Result { + let key = match self { + Self::Pem { pem } => build_from_pem_bytes(pem.as_bytes(), "inline PEM")?, + Self::PemFile { path } => { + let bytes = std::fs::read(path) + .map_err(|e| format!("decoding-key file '{}' unreadable: {e}", path.display()))?; + build_from_pem_bytes(&bytes, &format!("file '{}'", path.display()))? + } + Self::Jwk { jwk } => build_from_jwk_value(jwk)?, + Self::JwksUrl { url, .. } => { + return Err(format!( + "JwksUrl source '{url}' requires async resolution — call build_async()" + )) + } + Self::Secret { secret } => DecodingKey::from_secret(secret.as_bytes()), + }; + Ok(KeyStore::single_fallback(key)) + } + + /// Asynchronously resolve the source into a [`KeyStore`] — + /// handles every variant including `JwksUrl` (which does an + /// async HTTP GET against the IdP's JWKS endpoint and indexes + /// every signature-use key by its `kid`). + /// + /// Called from `JwtIdentityResolver::initialize()` so the host's + /// PluginManager can drive multiple resolvers' JWKS fetches + /// concurrently via `futures::join_all`. + /// + /// The fetch is bounded by `JWKS_FETCH_TIMEOUT` to prevent a + /// slow or hostile JWKS endpoint from hanging gateway startup + /// indefinitely. A timed-out fetch surfaces as an error string + /// the caller can soft-fail on (Slice B). + /// + /// **v0 caveat (still open after Slice A):** + /// + /// * No automatic rotation — the store is bound at initialize + /// time. Slice B adds a background refresh task so IdP key + /// rolls don't require a gateway restart. + pub async fn build_async(&self) -> Result { + match self { + Self::JwksUrl { url, insecure_http, .. } => { + // Reject http:// by default. Fetching JWKS over + // plaintext lets anyone on the network path swap the + // signing keys and forge JWTs the gateway accepts. + require_https(url, *insecure_http)?; + + // Build a Client with both a connect timeout and an + // overall request timeout. Without these a slow or + // half-open JWKS endpoint hangs the initialize() call + // indefinitely. The defaults are conservative; if a + // future config wants per-issuer override, add a + // `jwks_timeout_secs` field on `JwksUrl`. + let client = reqwest::Client::builder() + .timeout(JWKS_FETCH_TIMEOUT) + .connect_timeout(JWKS_CONNECT_TIMEOUT) + .build() + .map_err(|e| format!("JWKS client construction failed: {e}"))?; + + let body = client + .get(url) + .send() + .await + .map_err(|e| format!("JWKS GET {url} failed: {e}"))? + .error_for_status() + .map_err(|e| format!("JWKS GET {url} returned non-2xx: {e}"))? + .text() + .await + .map_err(|e| format!("JWKS GET {url} body read failed: {e}"))?; + + let jwks: jsonwebtoken::jwk::JwkSet = serde_json::from_str(&body) + .map_err(|e| format!("JWKS {url} body is not a JWKSet: {e}"))?; + + // Iterate every signature-use key (or every key, if + // none declared `use: sig`) and index by `kid`. + // OIDC spec requires JWKS entries to carry a `kid`; + // any entry missing one is dropped with a clear + // diagnostic appended to the error string. If NO + // usable keys remain, treat that as a config error. + let mut entries: Vec<(String, DecodingKey)> = Vec::new(); + let mut skipped_no_kid: usize = 0; + let mut skipped_unusable: Vec = Vec::new(); + for k in &jwks.keys { + // Filter to sig-use when the IdP labels it; if no + // key declares `use`, accept everything (some + // older IdPs publish JWKS without the field). + let use_field = k.common.public_key_use.as_ref(); + if use_field + .map(|u| *u != jsonwebtoken::jwk::PublicKeyUse::Signature) + .unwrap_or(false) + { + continue; + } + let kid = match k.common.key_id.as_deref() { + Some(kid) if !kid.is_empty() => kid.to_string(), + _ => { + skipped_no_kid += 1; + continue; + } + }; + match DecodingKey::from_jwk(k) { + Ok(key) => entries.push((kid, key)), + Err(e) => skipped_unusable.push(format!("{kid}: {e}")), + } + } + if entries.is_empty() { + return Err(format!( + "JWKS at {url} contained no usable signature keys \ + (skipped {skipped_no_kid} entries with no kid; \ + {} entries failed to parse: [{}])", + skipped_unusable.len(), + skipped_unusable.join(", "), + )); + } + Ok(KeyStore::from_jwks_entries(entries)) + } + // Non-network variants delegate to the sync path; they + // don't await anything, so the cost is zero vs. a direct + // sync call. + other => other.build(), + } + } +} + +/// Overall request timeout on the JWKS HTTP GET (includes connect + +/// TLS + response body). 5s is a forgiving upper bound for a healthy +/// IdP; anything slower than that is operationally indistinguishable +/// from "JWKS is down." +const JWKS_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// TCP-connect timeout for the JWKS HTTP GET. Separate from the +/// overall timeout so a hostile JWKS endpoint that accepts the +/// connection and then stalls on the response still fails fast. +const JWKS_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// PEM helper used by both `Pem` and `PemFile`. Tries RSA, then EC, +/// then EdDSA — covers the algorithms `jsonwebtoken` supports. +fn build_from_pem_bytes(bytes: &[u8], origin: &str) -> Result { + DecodingKey::from_rsa_pem(bytes) + .or_else(|_| DecodingKey::from_ec_pem(bytes)) + .or_else(|_| DecodingKey::from_ed_pem(bytes)) + .map_err(|e| format!("{origin} PEM key failed to parse: {e}")) +} + +fn build_from_jwk_value(jwk: &serde_json::Value) -> Result { + let parsed: jsonwebtoken::jwk::Jwk = serde_json::from_value(jwk.clone()) + .map_err(|e| format!("JWK is not well-formed: {e}"))?; + DecodingKey::from_jwk(&parsed).map_err(|e| format!("JWK not usable: {e}")) +} + +impl TrustedIssuerConfig { + /// Validate shape (non-empty issuer, at least one algorithm) + /// without resolving the key. Used at construction time as a + /// fast-fail gate so misshapen YAML is rejected before any + /// network I/O is attempted. + pub fn validate(&self) -> Result<(), String> { + if self.issuer.trim().is_empty() { + return Err("trusted_issuer.issuer must be non-empty".into()); + } + if self.algorithms.is_empty() { + return Err(format!( + "trusted_issuer '{}' must list at least one algorithm", + self.issuer + )); + } + Ok(()) + } + + /// Synchronously build a runtime `TrustedIssuer`. Works for + /// inline / on-disk `decoding_key` sources; **errors when + /// `decoding_key.kind == jwks_url`** — use [`build_async`] for + /// those. + /// + /// [`build_async`]: Self::build_async + pub fn build(self) -> Result { + self.validate()?; + let keys = self.decoding_key.build().map_err(|e| { + format!( + "trusted_issuer '{}' decoding_key build failed: {e}", + self.issuer + ) + })?; + Ok(TrustedIssuer { + issuer: self.issuer, + audiences: self.audiences, + keys: std::sync::Arc::new(std::sync::RwLock::new(keys)), + algorithms: self.algorithms, + leeway_seconds: self.leeway_seconds, + }) + } + + /// Asynchronously build a `TrustedIssuer`, handling every + /// `decoding_key` variant including `JwksUrl`. Called from + /// `JwtIdentityResolver::initialize()` for sources that deferred + /// resolution past construction. + pub async fn build_async(self) -> Result { + self.validate()?; + let keys = self.decoding_key.build_async().await.map_err(|e| { + format!( + "trusted_issuer '{}' decoding_key build failed: {e}", + self.issuer + ) + })?; + Ok(TrustedIssuer { + issuer: self.issuer, + audiences: self.audiences, + keys: std::sync::Arc::new(std::sync::RwLock::new(keys)), + algorithms: self.algorithms, + leeway_seconds: self.leeway_seconds, + }) + } +} + +/// Reject `http://` URLs for endpoints that carry trust-establishing +/// material. `https://` is always allowed; `http://` is allowed only +/// when `insecure_http` is `true`. Anything else (missing scheme, +/// data URLs, ...) returns Ok and lets the underlying parser surface +/// its own error. +fn require_https(url: &str, insecure_http: bool) -> Result<(), String> { + let lowered = url.trim_start().to_ascii_lowercase(); + if lowered.starts_with("https://") { + return Ok(()); + } + if lowered.starts_with("http://") { + if insecure_http { + return Ok(()); + } + return Err(format!( + "JWKS URL must use https:// (got '{url}'). Set `insecure_http: true` \ + to allow plaintext for localhost/dev only — never production." + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn jwks_https_accepted() { + assert!(require_https("https://idp.example/realms/x/jwks", false).is_ok()); + } + + #[test] + fn jwks_http_rejected_by_default() { + let err = require_https("http://localhost:8081/jwks", false).unwrap_err(); + assert!(err.contains("https"), "{}", err); + assert!(err.contains("insecure_http"), "{}", err); + } + + #[test] + fn jwks_http_with_explicit_opt_in_allowed() { + assert!(require_https("http://localhost:8081/jwks", true).is_ok()); + } + + #[tokio::test] + async fn jwks_http_url_rejected_at_build_async() { + let src = DecodingKeySource::JwksUrl { + url: "http://idp.example/jwks".into(), + insecure_http: false, + refresh_secs: 3600, + }; + match src.build_async().await { + Err(e) => assert!(e.contains("https"), "{}", e), + Ok(_) => panic!("http:// JWKS URL must not build by default"), + } + } + + #[test] + fn decoding_key_source_secret_builds() { + let src = DecodingKeySource::Secret { + secret: "test-secret".into(), + }; + assert!(src.build().is_ok()); + } + + #[test] + fn decoding_key_source_pem_rejects_garbage() { + // `DecodingKey` doesn't implement Debug (it carries key + // material), so `expect_err` won't compile here — match + // the Err arm directly instead. + let src = DecodingKeySource::Pem { + pem: "not actually pem".into(), + }; + match src.build() { + Err(msg) => assert!(msg.contains("failed to parse")), + Ok(_) => panic!("garbage PEM should have failed"), + } + } + + #[test] + fn config_deserializes_from_json() { + // The shape operators write in unified-config YAML, just + // serialized as JSON for the test. + let raw = json!({ + "trusted_issuers": [{ + "issuer": "https://idp.example.com", + "audiences": ["my-api"], + "algorithms": ["HS256"], + "decoding_key": { + "kind": "secret", + "secret": "test-secret", + }, + "leeway_seconds": 30, + }], + "claim_mapper": "standard", + }); + let cfg: JwtIdentityResolverConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.trusted_issuers.len(), 1); + assert_eq!(cfg.trusted_issuers[0].issuer, "https://idp.example.com"); + assert_eq!(cfg.claim_mapper.as_deref(), Some("standard")); + } +} diff --git a/crates/apl-identity-jwt/src/factory.rs b/crates/apl-identity-jwt/src/factory.rs new file mode 100644 index 00000000..3306c4db --- /dev/null +++ b/crates/apl-identity-jwt/src/factory.rs @@ -0,0 +1,60 @@ +// Location: ./crates/apl-identity-jwt/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `PluginFactory` impl for the JWT identity resolver. Lives in this +// crate (not in any consuming integration) so that every host — +// Praxis filter, Envoy bridge, CLI test harness — wires it up the +// same way. +// +// Operators declare it in CPEX YAML as: +// +// plugins: +// - name: jwt-resolver +// kind: identity/jwt +// hooks: [identity.resolve] +// config: +// trusted_issuers: +// - issuer: https://idp.example.com +// audiences: [my-api] +// algorithms: [RS256] +// decoding_key: { kind: jwks_url, url: ... } +// +// The `kind: identity/jwt` string is part of this crate's public API. +// Hosts call `mgr.register_factory("identity/jwt", Box::new(JwtIdentityFactory))` +// before `load_config_yaml`. + +use std::sync::Arc; + +use cpex_core::{ + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + identity::{IdentityHook, HOOK_IDENTITY_RESOLVE}, + plugin::PluginConfig, +}; + +use crate::JwtIdentityResolver; + +/// The plugin `kind:` string operators write in CPEX YAML to declare +/// a JWT identity resolver. +pub const KIND: &str = "identity/jwt"; + +/// Factory for `kind: identity/jwt` plugins. Instantiates a +/// `JwtIdentityResolver` from the `config:` block and registers it on +/// the `identity.resolve` hook. +pub struct JwtIdentityFactory; + +impl PluginFactory for JwtIdentityFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let resolver = Arc::new(JwtIdentityResolver::new(config.clone())?); + let handler = Arc::new(TypedHandlerAdapter::::new(Arc::clone( + &resolver, + ))); + Ok(PluginInstance { + plugin: resolver, + handlers: vec![(HOOK_IDENTITY_RESOLVE, handler)], + }) + } +} diff --git a/crates/apl-identity-jwt/src/lib.rs b/crates/apl-identity-jwt/src/lib.rs new file mode 100644 index 00000000..2dff2158 --- /dev/null +++ b/crates/apl-identity-jwt/src/lib.rs @@ -0,0 +1,61 @@ +// Location: ./crates/apl-identity-jwt/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-identity-jwt — JWT-based `IdentityResolveHandler` for APL. +// +// Validates inbound JWTs against configured trusted issuers and +// maps validated claims into the request's `IdentityPayload` +// (subject / client / raw_credentials slots). Designed as the +// lightweight identity path that pairs with `apl-cedarling`'s +// PDP role — operators wanting both run identity here, policy +// gating through `cedarling:` steps. +// +// Sub-step A scope: data shapes + module structure only. Actual +// validation logic in sub-step B; multi-issuer + key rotation in +// sub-step C; integration tests in sub-step D. +// +// # Error handling +// +// No bespoke error type. Two surfaces: +// +// * **Build / config errors** — constructors return +// `Result>`. Bad PEM, missing issuer +// URL, etc. surface as `PluginError::Config { message }`. +// * **Runtime token-rejection errors** — handler returns +// `PluginResult::deny(PluginViolation::new(code, reason))`. +// `code` is a stable identifier the host can map to HTTP +// status (`auth.token_expired`, `auth.signature_invalid`, +// `auth.untrusted_issuer`, …); `reason` is the operator- +// readable message. +// +// # When to use this vs alternatives +// +// - **`apl-identity-jwt`** (this crate) — JWT-only flow. +// Lightweight, ~5-15 transitive deps. The default choice for +// "validate a Bearer token, extract identity." +// - **`apl-cedarling`** as identity (deferred) — Cedarling's API +// doesn't expose validated entities to callers, so we deferred +// wiring it as an IdentityResolveHandler. Use this crate for +// validation + a `cedarling:` step early in the route policy +// block if you want policy-driven identity gating. +// - **Custom resolver** — anyone with bespoke identity flows +// (mTLS-only, opaque tokens with introspection, capability +// tokens) writes their own `HookHandler`. This +// crate's API surface is the reference shape but nothing +// prevents other resolvers from coexisting. + +pub mod claim_map; +pub mod config; +pub mod factory; +pub mod resolver; +pub mod trusted_issuer; + +pub use claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; +pub use config::{ + DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig, +}; +pub use factory::{JwtIdentityFactory, KIND}; +pub use resolver::JwtIdentityResolver; +pub use trusted_issuer::TrustedIssuer; diff --git a/crates/apl-identity-jwt/src/resolver.rs b/crates/apl-identity-jwt/src/resolver.rs new file mode 100644 index 00000000..b7c517f1 --- /dev/null +++ b/crates/apl-identity-jwt/src/resolver.rs @@ -0,0 +1,834 @@ +// Location: ./crates/apl-identity-jwt/src/resolver.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `JwtIdentityResolver` — `HookHandler` that validates +// inbound JWTs and populates the request's `IdentityPayload`. +// +// # Construction +// +// Single entry point: `JwtIdentityResolver::new(cfg: PluginConfig)`. +// Reads `cfg.config` (the typed plugin-specific config field) and +// deserializes it into [`JwtIdentityResolverConfig`], builds the +// runtime `TrustedIssuer` list and the `ClaimMapper`. No alternate +// constructors that bypass the config-driven path — tests +// construct a `PluginConfig` with the right `config` value and go +// through `new` like production code does. +// +// # Runtime flow +// +// 1. Peek at the `iss` claim *without* validating to pick the +// right trusted issuer config. +// 2. Validate the token (signature + exp + nbf + aud + iss) using +// that issuer's `DecodingKey`. `iss` is re-checked here as +// defense-in-depth. +// 3. Map validated claims to a `SubjectExtension` via the +// configured claim mapper. +// 4. Stash the raw token in `RawCredentialsExtension.inbound_tokens` +// under `TokenRole::User` for forwarding plugins downstream. +// 5. Return the updated payload via `PluginResult::modify_payload`. +// +// # Error handling +// +// Construction errors → `Box` (`PluginError::Config`). +// Runtime token rejections → `PluginResult::deny(PluginViolation::new(code, reason))`. +// Stable codes for runtime denials: +// +// * `auth.malformed_header` — JWT structure wrong / empty token +// * `auth.untrusted_issuer` — `iss` not in trusted list +// * `auth.signature_invalid` — signature failed +// * `auth.token_expired` — `exp` in the past +// * `auth.token_not_yet_valid` — `nbf` in the future +// * `auth.audience_mismatch` — `aud` didn't include any configured aud +// * `auth.algorithm_mismatch` — token uses unaccepted algo +// * `auth.mapping_failed` — claim mapper rejected the claims +// * `auth.token_invalid` — any other validation failure + +use std::sync::Arc; + +use async_trait::async_trait; +use base64::Engine; +use jsonwebtoken::{decode, Validation}; +use serde_json::Value; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, +}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::identity::{IdentityHook, IdentityPayload}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use super::claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; +use super::config::{JwtIdentityResolverConfig, TrustedIssuerConfig}; +use super::trusted_issuer::{KeyStore, TrustedIssuer}; + +/// Default clock-skew tolerance, in seconds. Matches what most OIDC +/// clients use as a sane default for `exp` / `nbf`. +const DEFAULT_LEEWAY_SECONDS: u64 = 60; + +/// JWT-based identity resolver. See module docs. +/// +/// # Async key resolution +/// +/// Trusted-issuer keys come in two flavors: +/// +/// * **Inline / on-disk** (`Pem`, `PemFile`, `Jwk`, `Secret`) — built +/// eagerly during `new()`. They appear in `trusted_issuers` +/// immediately after construction. +/// * **`JwksUrl`** — deferred to `Plugin::initialize()`. The configs +/// sit in `pending_jwks` until `initialize()` runs; that hook +/// fetches all pending JWKS endpoints **concurrently** via +/// `futures::join_all` and merges the resolved issuers into the +/// `trusted_issuers` vec under the `RwLock`. +/// +/// The split keeps construction synchronous (matches the existing +/// `PluginFactory::create` trait surface across the workspace) while +/// putting the network I/O on the natural async hook the host +/// already drives via `PluginManager::initialize().await`. +#[derive(Debug)] +pub struct JwtIdentityResolver { + cfg: PluginConfig, + trusted_issuers: std::sync::RwLock>, + /// Issuer configs whose `decoding_key` is a `JwksUrl` — + /// resolved during `initialize()`. Empty in deployments with + /// only inline sources. + pending_jwks: Vec, + claim_mapper: Arc, + /// Which identity slot this resolver fills. Drives + /// `IdentityPayload` slot selection and the `TokenRole` key under + /// which the raw token gets stashed in + /// `RawCredentialsExtension.inbound_tokens`. + role: TokenRole, + /// HTTP header this resolver reads its token from + /// (e.g. `X-User-Token`). Plugins that share a request extract + /// from different headers; the value lands on + /// `RawInboundToken.source_header` so forwarding plugins know + /// where to put it (or strip it) on the upstream call. + header: String, + /// Background JWKS-refresh tasks, one per JwksUrl issuer. + /// Spawned during `initialize()`. Aborted in the resolver's + /// `Drop` impl — without that, tokio JoinHandles silently + /// detach the task and the refresh loop runs forever (until + /// the runtime shuts down or it panics). + refresh_tasks: std::sync::Mutex>>, +} + +impl JwtIdentityResolver { + /// Build a resolver from a `PluginConfig`. Reads `cfg.config` + /// (the plugin-specific config field — `Option`), + /// deserializes it into [`JwtIdentityResolverConfig`], builds + /// the runtime `TrustedIssuer` list, and resolves the claim + /// mapper by name. + /// + /// Returns `PluginError::Config` for any config-time failure: + /// missing config block, malformed JSON, no trusted issuers, + /// unparseable decoding key, unknown claim mapper, etc. + pub fn new(cfg: PluginConfig) -> Result> { + let raw_config = cfg.config.as_ref().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt) requires a `config:` block — \ + missing trusted_issuers etc.", + cfg.name + ), + }) + })?; + + let typed: JwtIdentityResolverConfig = serde_json::from_value(raw_config.clone()) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt) config parse failed: {e}", + cfg.name + ), + }) + })?; + + if typed.trusted_issuers.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt) requires at least one \ + entry in `trusted_issuers`", + cfg.name + ), + })); + } + + // Partition issuer configs: + // * Inline / on-disk decoding keys (Pem, PemFile, Jwk, + // Secret) → eagerly built into TrustedIssuers here. + // * JwksUrl decoding keys → deferred to initialize() so + // the host's PluginManager can drive the HTTP fetches + // concurrently across all resolvers. + let mut trusted_issuers: Vec = Vec::new(); + let mut pending_jwks: Vec = Vec::new(); + for raw in typed.trusted_issuers { + // Validate shape eagerly so bad YAML fails at load_config + // rather than at the async initialize() boundary. + raw.validate().map_err(|e| { + Box::new(PluginError::Config { + message: format!("plugin '{}' (apl-identity-jwt): {e}", cfg.name), + }) + })?; + if raw.decoding_key.needs_async() { + pending_jwks.push(raw); + } else { + let built = raw.build().map_err(|e| { + Box::new(PluginError::Config { + message: format!("plugin '{}' (apl-identity-jwt): {e}", cfg.name), + }) + })?; + trusted_issuers.push(built); + } + } + + // Resolve the claim mapper by name. Unknown names are a + // config error rather than a silent fallback — fail fast + // so operators notice typos. + let claim_mapper: Arc = match typed.claim_mapper.as_deref() { + None | Some("standard") => Arc::new(StandardClaimMap), + Some(other) => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt): unknown claim_mapper \ + '{other}'; valid: [standard]", + cfg.name + ), + })); + } + }; + + // Reject `role: Custom(...)` at construction — the framework + // has slots for User / Client / Workload (the three named + // entries on SecurityExtension). Custom roles would write to + // `inbound_tokens` only, with no SecurityExtension home, so + // downstream `subject.*` / `client.*` predicates wouldn't see + // them. If we ever want custom slots, that's its own slice. + if matches!(typed.role, TokenRole::Custom(_)) { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt): role: Custom(...) is not \ + yet supported — pick one of `user`, `client`, `workload`", + cfg.name + ), + })); + } + if typed.header.trim().is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-identity-jwt): `header:` must be a \ + non-empty HTTP header name", + cfg.name + ), + })); + } + + Ok(Self { + cfg, + trusted_issuers: std::sync::RwLock::new(trusted_issuers), + pending_jwks, + claim_mapper, + role: typed.role, + header: typed.header, + refresh_tasks: std::sync::Mutex::new(Vec::new()), + }) + } +} + +impl Drop for JwtIdentityResolver { + /// Stop every background refresh task when the resolver drops. + /// Without this, `tokio::task::JoinHandle` *detaches* on drop + /// — the refresh loop keeps running until the tokio runtime + /// shuts down. That's harmless for the program-lifetime + /// singleton case but creates orphan tasks during plugin + /// hot-reload or in tests that construct/discard resolvers + /// repeatedly. + fn drop(&mut self) { + let mut tasks = match self.refresh_tasks.lock() { + Ok(t) => t, + Err(poisoned) => poisoned.into_inner(), + }; + for handle in tasks.drain(..) { + handle.abort(); + } + } +} + +#[async_trait] +impl Plugin for JwtIdentityResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } + + /// Resolve any `JwksUrl` decoding keys deferred at construction, + /// then spawn a background task per JwksUrl issuer to refresh + /// the KeyStore on a periodic schedule (default 10 min, + /// configurable per-issuer via `refresh_secs`). + /// + /// **Soft-fail semantics (Slice B):** an unreachable / slow / + /// malformed JWKS at startup logs a warning and leaves the + /// issuer's KeyStore *empty*. The plugin still loads, the + /// gateway still boots, and the background refresh task gets + /// spawned anyway — so a transient IdP outage during boot + /// recovers on its own as soon as refresh succeeds. Verify-time + /// requests against an issuer with an empty KeyStore receive + /// `auth.jwks_unavailable` rather than crashing the request. + /// + /// Initial fetches happen concurrently — N pending issuers + /// → one `join_all`, not N sequential round-trips — so the + /// time-to-ready scales with the slowest IdP, not the sum. + /// + /// The `PluginManager` drives this once per plugin lifetime + /// (before any hooks fire). Idempotent: if `pending_jwks` is + /// empty (no JwksUrl sources) this is a free no-op. + async fn initialize(&self) -> Result<(), Box> { + if self.pending_jwks.is_empty() { + return Ok(()); + } + + // 1. Initial concurrent fetch. Each result is (config, + // outcome) — we keep the config alongside the result + // so the soft-fail path can construct an empty + // KeyStore *and* still spawn refresh for that issuer. + let fetches = self.pending_jwks.iter().cloned().map(|cfg| async move { + let outcome = cfg.clone().build_async().await; + (cfg, outcome) + }); + let resolved: Vec<(TrustedIssuerConfig, Result)> = + futures::future::join_all(fetches).await; + + let mut issuers = self + .trusted_issuers + .write() + .unwrap_or_else(|p| p.into_inner()); + let mut new_tasks: Vec> = Vec::new(); + + for (cfg, outcome) in resolved { + // Get the shared store: from the successful fetch's + // TrustedIssuer if we have one, else an empty store + // bound to a freshly-constructed TrustedIssuer shell. + // Either way we end up with one TrustedIssuer in + // `issuers` and a clone of its `Arc>` + // captured by the refresh task. + let (shared, plugin_name) = (self.cfg.name.clone(), cfg.issuer.clone()); + let issuer = match outcome { + Ok(iss) => iss, + Err(e) => { + tracing::warn!( + plugin = %shared, + issuer = %plugin_name, + error = %e, + "initial JWKS fetch failed; soft-fail. Verify requests \ + against this issuer will receive auth.jwks_unavailable \ + until refresh succeeds." + ); + // Build a TrustedIssuer with an empty KeyStore + // so the refresh task can swap a fresh store in + // without re-running validation logic. + TrustedIssuer { + issuer: cfg.issuer.clone(), + audiences: cfg.audiences.clone(), + keys: Arc::new(std::sync::RwLock::new(KeyStore::empty())), + algorithms: cfg.algorithms.clone(), + leeway_seconds: cfg.leeway_seconds, + } + } + }; + + // Spawn refresh task. The closure owns: + // - a clone of the source (cfg.decoding_key) for + // re-fetching + // - a clone of the Arc> for atomic + // whole-store replacement on success + // - plugin / issuer names for diagnostic logging + if let Some(interval) = cfg.decoding_key.refresh_interval() { + let source = cfg.decoding_key.clone(); + let shared_store = Arc::clone(&issuer.keys); + let plugin_label = self.cfg.name.clone(); + let issuer_label = cfg.issuer.clone(); + let handle = tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + // Skip the first immediate tick — the initial + // fetch already ran synchronously above. The + // first refresh fires at `now + interval`. + ticker.tick().await; + loop { + ticker.tick().await; + match source.build_async().await { + Ok(new_store) => { + // Whole-store replacement. The + // old store drops when the write + // completes — bounded steady-state + // memory regardless of how many + // rotations have happened. + match shared_store.write() { + Ok(mut g) => *g = new_store, + Err(poisoned) => *poisoned.into_inner() = new_store, + } + tracing::info!( + plugin = %plugin_label, + issuer = %issuer_label, + "JWKS refresh succeeded" + ); + } + Err(e) => { + tracing::warn!( + plugin = %plugin_label, + issuer = %issuer_label, + error = %e, + "JWKS refresh failed; keeping previous KeyStore" + ); + } + } + } + }); + new_tasks.push(handle); + } + + issuers.push(issuer); + } + + // Park the handles so Drop can abort them. Held under a + // std::sync::Mutex because the resolver's outer methods are + // a mix of sync and async; we don't await while holding it. + let mut tasks = self + .refresh_tasks + .lock() + .unwrap_or_else(|p| p.into_inner()); + tasks.extend(new_tasks); + + Ok(()) + } +} + +impl HookHandler for JwtIdentityResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Read OUR configured header from the request's full header + // map. HTTP headers are case-insensitive (RFC 7230 §3.2); + // we lowercase the configured name to match the canonical + // form hosts use when populating the map. Fall back to + // `payload.raw_token()` only when no header map is populated + // — covers single-resolver back-compat for hosts that still + // pre-extract one token. + let header_lc = self.header.to_ascii_lowercase(); + let header_value = payload.headers().get(header_lc.as_str()); + let raw_token: String = match header_value { + Some(v) => v.strip_prefix("Bearer ").unwrap_or(v).to_string(), + None if !payload.raw_token().is_empty() => payload.raw_token().to_string(), + None => { + return PluginResult::deny(PluginViolation::new( + "auth.malformed_header", + format!( + "header '{}' missing from request (resolver '{}' / role '{:?}')", + self.header, self.cfg.name, self.role + ), + )); + } + }; + if raw_token.is_empty() { + return PluginResult::deny(PluginViolation::new( + "auth.malformed_header", + format!("header '{}' is present but empty", self.header), + )); + } + + // 1. Peek at `iss` to find the matching TrustedIssuer config. + let iss = match peek_issuer(&raw_token) { + Some(iss) => iss, + None => { + return PluginResult::deny(PluginViolation::new( + "auth.malformed_header", + "JWT not well-formed or missing `iss` claim", + )); + } + }; + // Read-lock the issuer list. After `initialize()` it's + // immutable for the resolver's lifetime; reads are cheap. + // Recover from a poisoned lock (a panic somewhere else + // while holding the write lock) — the data is still valid. + let issuers = self + .trusted_issuers + .read() + .unwrap_or_else(|p| p.into_inner()); + let issuer = match issuers.iter().find(|i| i.issuer == iss) { + Some(i) => i, + None => { + return PluginResult::deny(PluginViolation::new( + "auth.untrusted_issuer", + format!("issuer '{iss}' is not in the trusted-issuer list"), + )); + } + }; + + // 2. Validate signature + standard claims, after kid-driven + // key selection. Three distinct deny codes so operators + // can tell: + // - rotation lag (`auth.unknown_kid`): the IdP rolled + // and our refresh hasn't yet pulled the new key. + // - JWKS-unavailable (`auth.jwks_unavailable`): the + // initial fetch failed and refresh hasn't recovered + // — the gateway didn't crash by design, but it + // also can't verify tokens for this issuer right now. + // - forgery / corruption (`auth.signature_invalid` and + // friends): the standard jsonwebtoken outcomes. + let token_data = match validate_token(&raw_token, issuer) { + Ok(td) => td, + Err(ValidateError::KeysUnavailable) => { + return PluginResult::deny(PluginViolation::new( + "auth.jwks_unavailable", + format!( + "issuer '{iss}' has no signing keys available — \ + initial JWKS fetch failed and refresh has not \ + yet succeeded; check upstream IdP reachability" + ), + )); + } + Err(ValidateError::UnknownKid(kid)) => { + let reason = match kid { + Some(k) => format!( + "token's header `kid` = '{k}' did not match any key in issuer's JWKS" + ), + None => "token has no `kid` header; issuer's JWKS keys all require kid match" + .to_string(), + }; + return PluginResult::deny(PluginViolation::new("auth.unknown_kid", reason)); + } + Err(ValidateError::Jwt(e)) => { + let (code, reason) = classify_jwt_error(&e); + return PluginResult::deny(PluginViolation::new(code, reason)); + } + }; + + // 3. Build the updated payload by mapping claims into the + // typed slot for our configured role. + let mut updated = payload.clone(); + match &self.role { + TokenRole::User => match self.claim_mapper.map_subject(&token_data.claims) { + Some(s) => updated.subject = Some(s), + None => { + return PluginResult::deny(PluginViolation::new( + "auth.mapping_failed", + "claim mapper produced no subject — required `sub` \ + claim missing or wrong shape", + )); + } + }, + TokenRole::Client => match self.claim_mapper.map_client(&token_data.claims) { + Some(c) => updated.client = Some(c), + None => { + return PluginResult::deny(PluginViolation::new( + "auth.mapping_failed", + "claim mapper produced no client — required `client_id` \ + / `azp` claim missing", + )); + } + }, + TokenRole::Workload => match self.claim_mapper.map_workload(&token_data.claims) { + Some(w) => updated.caller_workload = Some(w), + None => { + return PluginResult::deny(PluginViolation::new( + "auth.mapping_failed", + "claim mapper produced no workload — token doesn't look \ + like a SPIFFE-JWT-SVID (sub doesn't start with `spiffe://`)", + )); + } + }, + TokenRole::Custom(_) => { + // Filtered out at construction; defense in depth. + return PluginResult::deny(PluginViolation::new( + "auth.misconfigured", + "role: Custom(...) is not supported", + )); + } + // TokenRole is #[non_exhaustive]; future variants must be + // explicitly handled. Until then, treat unknown roles the + // same as Custom — surface as misconfigured rather than + // silently dropping the token. + _ => { + return PluginResult::deny(PluginViolation::new( + "auth.misconfigured", + "unsupported TokenRole variant", + )); + } + } + + // 4. Stash the raw token for forwarding plugins. Key the + // stash by the resolver's configured role so multi-token + // deployments (user + client + workload) keep each + // credential addressable. + let mut raw_creds = updated + .raw_credentials + .clone() + .unwrap_or_else(RawCredentialsExtension::default); + raw_creds.inbound_tokens.insert( + self.role.clone(), + RawInboundToken::new(raw_token, self.header.clone(), TokenKind::Jwt), + ); + updated.raw_credentials = Some(raw_creds); + updated.resolved_at = Some(chrono::Utc::now()); + // Pass the full claim map through `raw_claims` so audit / + // downstream policy that wants uncategorized claims has them. + // For multi-resolver chains, the last resolver wins; if + // operators need per-role raw claims they should read from + // the typed slots (subject.claims / client.claims) instead. + updated.raw_claims = token_data.claims; + + PluginResult::modify_payload(updated) + } +} + +// ===================================================================== +// Internal helpers +// ===================================================================== + +/// Pull the `iss` claim out of a JWT *without* verifying the +/// signature. Used purely to look up which trusted issuer config +/// to validate against next. +/// +/// **Security note:** the value returned here is untrusted until +/// the subsequent validation pass succeeds. We use it only to +/// select the right `DecodingKey`; validation re-enforces `iss` +/// against the matched config. +fn peek_issuer(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .ok()?; + let value: Value = serde_json::from_slice(&payload_bytes).ok()?; + value.get("iss")?.as_str().map(String::from) +} + +/// Reason `validate_token` couldn't verify the JWT. Wraps the +/// usual `jsonwebtoken::errors::Error` plus the kid-selection +/// and JWKS-availability cases introduced by Slice A / B. +enum ValidateError { + /// The JWT's header `kid` didn't match any key the issuer's + /// KeyStore knows about. Distinct from `InvalidSignature` so + /// the verify path can surface `auth.unknown_kid` with the + /// specific kid that was missing — operators can match this + /// against their IdP's currently-published JWKS to confirm + /// rotation propagated. + UnknownKid(Option), + /// The issuer's KeyStore is empty: initial JWKS fetch failed + /// at `initialize()`, refresh task hasn't yet succeeded. The + /// gateway didn't crash (soft-fail by design), but it also + /// can't verify any token from this issuer until refresh + /// catches up. Surfaces as `auth.jwks_unavailable` so + /// operators see "JWKS issue at IdP X" rather than the more + /// alarming `auth.signature_invalid` they'd see if we + /// silently fell back to e.g. an empty key. + KeysUnavailable, + /// jsonwebtoken's own validation outcome (signature, exp, + /// nbf, iss, aud, algorithm). + Jwt(jsonwebtoken::errors::Error), +} + +/// Validate the token against the matched issuer's config: +/// `kid`-driven key selection, then signature, exp, nbf, aud, iss. +/// +/// Two-step lookup: +/// 1. Decode just the JWT header (no signature check yet) to +/// read the `kid` claim. We don't trust the result for +/// authorization decisions — we use it only to pick a +/// candidate key from the issuer's `KeyStore`. +/// 2. If a key is found, run jsonwebtoken's full validation +/// against it. Failure modes (bad sig, expired, etc.) flow +/// through unchanged. +/// 3. If no key matches, return `UnknownKid` — distinct from +/// `InvalidSignature` so operators can tell rotation lag +/// from a forgery attempt at the audit layer. +fn validate_token( + token: &str, + issuer: &TrustedIssuer, +) -> Result, ValidateError> { + let header = jsonwebtoken::decode_header(token).map_err(ValidateError::Jwt)?; + let kid = header.kid.as_deref(); + + // Acquire a read guard on the issuer's KeyStore. The guard is + // held for the duration of `decode()` below — sync, no .await + // between acquire and release, so no risk of deadlock against + // the refresh task's write lock. Refresh writes block until + // outstanding readers release; a verify in flight when refresh + // fires waits a few µs at most. + let keys = issuer + .keys + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if keys.is_empty() { + return Err(ValidateError::KeysUnavailable); + } + + let key = match keys.select(kid) { + Some(k) => k, + None => return Err(ValidateError::UnknownKid(kid.map(String::from))), + }; + + let primary = issuer.algorithms[0]; + let mut validation = Validation::new(primary); + validation.algorithms = issuer.algorithms.clone(); + validation.set_issuer(&[&issuer.issuer]); + validation.leeway = if issuer.leeway_seconds == 0 { + DEFAULT_LEEWAY_SECONDS + } else { + issuer.leeway_seconds + }; + if issuer.audiences.is_empty() { + validation.validate_aud = false; + } else { + let aud_refs: Vec<&str> = issuer.audiences.iter().map(String::as_str).collect(); + validation.set_audience(&aud_refs); + } + decode::(token, key, &validation).map_err(ValidateError::Jwt) +} + +/// Map jsonwebtoken errors to stable violation codes. +fn classify_jwt_error(e: &jsonwebtoken::errors::Error) -> (&'static str, String) { + use jsonwebtoken::errors::ErrorKind; + let code = match e.kind() { + ErrorKind::ExpiredSignature => "auth.token_expired", + ErrorKind::InvalidSignature => "auth.signature_invalid", + ErrorKind::ImmatureSignature => "auth.token_not_yet_valid", + ErrorKind::InvalidAudience => "auth.audience_mismatch", + ErrorKind::InvalidIssuer => "auth.untrusted_issuer", + ErrorKind::InvalidAlgorithm | ErrorKind::InvalidAlgorithmName => { + "auth.algorithm_mismatch" + } + ErrorKind::Base64(_) | ErrorKind::Json(_) => "auth.malformed_header", + _ => "auth.token_invalid", + }; + (code, e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use serde_json::json; + + fn jwt_with_payload(payload_json: &str) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + let sig = URL_SAFE_NO_PAD.encode(b"fake-signature"); + format!("{header}.{payload}.{sig}") + } + + fn cfg_with_config(name: &str, config: Value) -> PluginConfig { + PluginConfig { + name: name.into(), + config: Some(config), + ..Default::default() + } + } + + #[test] + fn new_rejects_missing_config_block() { + let cfg = PluginConfig { + name: "jwt".into(), + config: None, + ..Default::default() + }; + let err = JwtIdentityResolver::new(cfg).expect_err("missing config should fail"); + assert!(format!("{err}").contains("config")); + } + + #[test] + fn new_rejects_empty_trusted_issuers() { + let cfg = cfg_with_config("jwt", json!({ "trusted_issuers": [] })); + let err = JwtIdentityResolver::new(cfg) + .expect_err("empty trusted_issuers should fail"); + assert!(format!("{err}").contains("trusted_issuers")); + } + + #[test] + fn new_rejects_unknown_claim_mapper() { + let cfg = cfg_with_config( + "jwt", + json!({ + "trusted_issuers": [{ + "issuer": "https://idp.example.com", + "algorithms": ["HS256"], + "decoding_key": { "kind": "secret", "secret": "x" }, + }], + "claim_mapper": "made-up-mapper", + }), + ); + let err = JwtIdentityResolver::new(cfg) + .expect_err("unknown mapper should fail"); + assert!(format!("{err}").contains("claim_mapper")); + } + + #[test] + fn new_accepts_well_formed_config() { + let cfg = cfg_with_config( + "jwt", + json!({ + "trusted_issuers": [{ + "issuer": "https://idp.example.com", + "audiences": ["my-api"], + "algorithms": ["HS256"], + "decoding_key": { "kind": "secret", "secret": "test-secret" }, + "leeway_seconds": 30, + }], + "claim_mapper": "standard", + }), + ); + let resolver = JwtIdentityResolver::new(cfg).expect("should construct"); + let issuers = resolver.trusted_issuers.read().unwrap(); + assert_eq!(issuers.len(), 1); + assert_eq!(issuers[0].issuer, "https://idp.example.com"); + // Secret source resolves eagerly — no pending JWKS work. + assert!(resolver.pending_jwks.is_empty()); + } + + #[test] + fn peek_issuer_extracts_iss() { + let token = jwt_with_payload(r#"{"sub":"alice","iss":"https://idp.example.com"}"#); + assert_eq!( + peek_issuer(&token), + Some("https://idp.example.com".to_string()), + ); + } + + #[test] + fn peek_issuer_returns_none_for_malformed_token() { + assert!(peek_issuer("not.a-jwt").is_none()); + assert!(peek_issuer("a.b.c.d").is_none()); + assert!(peek_issuer("").is_none()); + } + + #[test] + fn peek_issuer_returns_none_when_iss_missing() { + let token = jwt_with_payload(r#"{"sub":"alice"}"#); + assert!(peek_issuer(&token).is_none()); + } + + #[test] + fn classify_picks_expected_codes() { + use jsonwebtoken::errors::{Error, ErrorKind}; + let cases = [ + (ErrorKind::ExpiredSignature, "auth.token_expired"), + (ErrorKind::InvalidSignature, "auth.signature_invalid"), + (ErrorKind::ImmatureSignature, "auth.token_not_yet_valid"), + (ErrorKind::InvalidAudience, "auth.audience_mismatch"), + (ErrorKind::InvalidIssuer, "auth.untrusted_issuer"), + ]; + for (kind, expected_code) in cases { + let err = Error::from(kind); + let (code, _reason) = classify_jwt_error(&err); + assert_eq!(code, expected_code); + } + } +} diff --git a/crates/apl-identity-jwt/src/trusted_issuer.rs b/crates/apl-identity-jwt/src/trusted_issuer.rs new file mode 100644 index 00000000..a5acf6d4 --- /dev/null +++ b/crates/apl-identity-jwt/src/trusted_issuer.rs @@ -0,0 +1,198 @@ +// Location: ./crates/apl-identity-jwt/src/trusted_issuer.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `TrustedIssuer` — config for one OIDC issuer the resolver trusts, +// plus the `KeyStore` that holds its (possibly-multiple) JWKS keys +// indexed by `kid` for token-header-driven key selection. + +use std::collections::HashMap; + +use jsonwebtoken::{Algorithm, DecodingKey}; + +/// A bundle of decoding keys for one trust anchor, supporting +/// `kid`-driven selection at verify time. +/// +/// JWKS endpoints commonly publish more than one key (rotation grace +/// windows, multi-algo deployments). The standard OIDC pattern is +/// for each token to declare which `kid` it was signed with in its +/// header; verifiers select the matching key from the JWKS rather +/// than picking the first-listed entry and hoping. +/// +/// Two slots: +/// - `by_kid`: keys with a JWKS-declared `kid`. The verify path +/// looks here first using the inbound token's header `kid`. +/// - `fallback`: a single key for the kid-less case. Populated +/// for inline sources (`Pem`/`PemFile`/`Jwk`/`Secret`) which +/// have no JWKS context. JWKS-sourced KeyStores leave this +/// `None` — every JWKS key carries a `kid` by spec. +/// +/// A KeyStore with no entries at all (`by_kid.is_empty() && fallback.is_none()`) +/// is a valid runtime state — it represents "JWKS fetch failed, +/// retry pending" in the soft-fail design (Slice B). Today every +/// construction path populates at least one slot before the store +/// is reachable from the resolver. +/// +/// # Update discipline (Slice B refresh) +/// +/// When the periodic refresh task lands, the intended pattern is +/// **whole-store replacement** — the refresh fetches a fresh JWKS, +/// builds a new `KeyStore`, and replaces the old one atomically +/// (`*shared.write().await = new_store`). Do **not** merge new +/// keys into the existing `by_kid` map: that grows unbounded as +/// the IdP rotates kids in and out over the deployment's lifetime +/// (every kid the IdP ever published stays in our map forever). +/// Whole-store replacement bounds the live key count to the +/// IdP's current JWKS size and lets dropped DecodingKeys release. +/// `RwLock` semantics make this race-free: in-flight verifies +/// holding `&DecodingKey` keep the old store alive until they +/// release, at which point the swap completes and the old store +/// drops. +pub struct KeyStore { + by_kid: HashMap, + fallback: Option, +} + +impl KeyStore { + /// Empty store. Only useful for the soft-fail placeholder path + /// (Slice B); current code always populates before exposing. + pub fn empty() -> Self { + Self { + by_kid: HashMap::new(), + fallback: None, + } + } + + /// Single-key store with no `kid`. Used by inline sources (Pem, + /// PemFile, Jwk, Secret) — they have no JWKS context to provide + /// a kid, so the key serves every token regardless of header. + pub fn single_fallback(key: DecodingKey) -> Self { + Self { + by_kid: HashMap::new(), + fallback: Some(key), + } + } + + /// Construct from a JWKS — every key gets indexed by its `kid`. + /// JWKS entries without a `kid` are silently dropped (the OIDC + /// spec requires them to carry one; an entry missing `kid` is + /// an IdP misconfiguration we'd rather surface as + /// `auth.unknown_kid` at verify time than as a silent + /// fallback-wins behaviour). + pub fn from_jwks_entries(entries: I) -> Self + where + I: IntoIterator, + { + Self { + by_kid: entries.into_iter().collect(), + fallback: None, + } + } + + /// Look up the key for a token's header `kid`. Returns: + /// - the matching kid'd key if `kid` is Some and present + /// - the fallback if `kid` is None and a fallback exists + /// - None otherwise (caller surfaces `auth.unknown_kid`) + /// + /// Deliberately does NOT silently fall back to `fallback` when + /// a kid'd lookup misses. With both behaviours mixed, an + /// attacker who controls JWKS body order could downgrade a + /// kid'd token to a fallback key. The kid'ed lookup is exact; + /// only kid-absent tokens may use the fallback. + pub fn select(&self, kid: Option<&str>) -> Option<&DecodingKey> { + match kid { + Some(k) => self.by_kid.get(k), + None => self.fallback.as_ref(), + } + } + + /// Diagnostic: how many keys this store knows about. Used in + /// log lines and the `Debug` impl below; not for control flow. + pub fn len(&self) -> usize { + self.by_kid.len() + usize::from(self.fallback.is_some()) + } + + /// Whether the store has any usable key. False only on the + /// Slice-B soft-fail placeholder path. + pub fn is_empty(&self) -> bool { + self.by_kid.is_empty() && self.fallback.is_none() + } +} + +// `DecodingKey` doesn't derive Debug (it carries key bytes; the lib +// avoids accidental log leakage). We elide every key value; only +// the count and kid set surface. +impl std::fmt::Debug for KeyStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let kids: Vec<&str> = self.by_kid.keys().map(String::as_str).collect(); + f.debug_struct("KeyStore") + .field("kids", &kids) + .field("has_fallback", &self.fallback.is_some()) + .finish() + } +} + +/// One issuer's trust config — `iss` value to match against, +/// audience to require, decoding key(s), and acceptable algorithms. +/// +/// Deployments with multiple IdPs construct one of these per IdP +/// and hand the list to `JwtIdentityResolver::new`. The resolver +/// picks the matching issuer based on the inbound token's `iss` +/// claim. +#[non_exhaustive] +pub struct TrustedIssuer { + /// Expected `iss` claim value — the resolver rejects tokens + /// whose `iss` doesn't match. + pub issuer: String, + + /// Expected audience(s). Tokens must carry at least one matching + /// `aud` value. Empty vec means "don't check audience" + /// (only acceptable for trusted-internal flows). + pub audiences: Vec, + + /// Decoding keys for this issuer, indexed by `kid`. For inline + /// sources (Pem/Jwk/Secret) this is a single-entry store with + /// no kid; for JWKS sources every advertised signature key + /// lands here so the verify path can pick the one matching the + /// inbound token's header. + /// + /// Wrapped in `Arc>` so the background JWKS + /// refresh task can atomically swap in a fresh KeyStore + /// without blocking concurrent verifies (read guards are held + /// for the duration of one `decode()`, which is sync — no + /// `.await` between acquisition and release, so no deadlock + /// risk and no contention beyond a few µs per request). + /// + /// Empty during the soft-fail boot path (initial JWKS fetch + /// failed, refresh task will retry). Verify checks for this + /// and returns `auth.jwks_unavailable` rather than the + /// `auth.unknown_kid` it would otherwise produce. + pub keys: std::sync::Arc>, + + /// Algorithms accepted for signature verification. Most + /// deployments stick to one (RS256 most commonly), but + /// supporting multiple lets the IdP rotate to a new algo + /// without us redeploying. + pub algorithms: Vec, + + /// Clock-skew tolerance for `exp` / `nbf` claims, in seconds. + /// Defaults applied in `JwtIdentityResolver::new`. + pub leeway_seconds: u64, +} + +// Manual `Debug` impl — `jsonwebtoken::DecodingKey` doesn't derive +// `Debug` (presumably to avoid leaking key material into logs). +// We elide the key entirely; the issuer URL + algorithms are +// enough for diagnostic output. +impl std::fmt::Debug for TrustedIssuer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrustedIssuer") + .field("issuer", &self.issuer) + .field("audiences", &self.audiences) + .field("algorithms", &self.algorithms) + .field("leeway_seconds", &self.leeway_seconds) + .field("keys", &self.keys) + .finish() + } +} diff --git a/crates/apl-identity-jwt/tests/jwks_url_e2e.rs b/crates/apl-identity-jwt/tests/jwks_url_e2e.rs new file mode 100644 index 00000000..b06e4518 --- /dev/null +++ b/crates/apl-identity-jwt/tests/jwks_url_e2e.rs @@ -0,0 +1,750 @@ +// Location: ./crates/apl-identity-jwt/tests/jwks_url_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for `DecodingKeySource::JwksUrl` + the async +// resolution path: +// +// 1. Construct a JwtIdentityResolver with `decoding_key.kind: +// jwks_url` pointing at a mockito server. The resolver carries +// the issuer config in `pending_jwks`; `trusted_issuers` is +// empty (no inline keys). +// 2. Call `plugin.initialize().await` — this is the async hook the +// host's `PluginManager::initialize()` drives. It triggers the +// JWKS HTTP fetch. +// 3. Mint a JWT with the corresponding private key, hand it to the +// resolver, assert the subject is populated. Proves the +// fetched JWKS key was wired into the trusted-issuer list. +// +// Also covers: missing-initialize sad path (the resolver returns +// `untrusted_issuer` because the JwksUrl-deferred issuer never made +// it into `trusted_issuers`). + +use std::sync::Arc; + +use cpex_core::hooks::payload::Extensions; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use apl_identity_jwt::{DecodingKeySource, JwtIdentityResolver}; + +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use mockito::Server; +use rsa::pkcs1::EncodeRsaPublicKey; +use rsa::pkcs8::{EncodePrivateKey, LineEnding}; +use rsa::traits::PublicKeyParts; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde_json::{json, Value}; + +const ISS: &str = "https://idp.test.local"; +const AUD: &str = "test-api"; + +/// Build a JWKS JSON document from a single RSA public key. The +/// `kid` is fixed and the key declares `use=sig, alg=RS256` so the +/// resolver picks it via the "first signing-use key" rule. +fn build_jwks(public: &RsaPublicKey) -> Value { + use base64::Engine; + let n_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(public.n().to_bytes_be()); + let e_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(public.e().to_bytes_be()); + json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "test-key-1", + "n": n_b64, + "e": e_b64, + }] + }) +} + +fn mint_jwt(private_pem: &str, claims: Value) -> String { + // Set `kid` so the resolver's KeyStore lookup hits — the JWKS + // entry exposed by the mock server uses the same kid value + // ("test-key-1", see `jwks_body`). + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("test-key-1".into()); + let key = EncodingKey::from_rsa_pem(private_pem.as_bytes()) + .expect("build EncodingKey from RSA PEM"); + encode(&header, &claims, &key).expect("sign JWT") +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +fn resolver_config(jwks_url: &str) -> PluginConfig { + PluginConfig { + name: "jwt-via-jwks".into(), + kind: "test".into(), + hooks: vec![HOOK_IDENTITY_RESOLVE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(json!({ + "role": "user", + "header": "Authorization", + "trusted_issuers": [{ + "issuer": ISS, + "audiences": [AUD], + "algorithms": ["RS256"], + // mockito serves over http://127.0.0.1 — opt in to + // plaintext for this test. Production deployments + // must omit `insecure_http`. + "decoding_key": { "kind": "jwks_url", "url": jwks_url, "insecure_http": true }, + "leeway_seconds": 60, + }], + "claim_mapper": "standard", + })), + ..Default::default() + } +} + +/// Verify that a JWT signed by the JWKS-published key validates +/// after `initialize()` resolves the JWKS URL. +#[tokio::test(flavor = "multi_thread")] +async fn initialize_fetches_jwks_and_validates_token() { + // 1. Generate a keypair and serve its public key as a JWKS. + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); + let pub_key = RsaPublicKey::from(&priv_key); + let priv_pem = priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(); + let jwks_body = build_jwks(&pub_key).to_string(); + // Suppress unused-import warning on EncodeRsaPublicKey — only + // exists to keep the trait in scope for callers that want + // alternate PEM exports. + let _ = pub_key.to_pkcs1_pem(LineEnding::LF); + + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(jwks_body) + .expect(1) + .create_async() + .await; + + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + + // 2. Build the resolver. JwksUrl source → trusted_issuers is + // empty until initialize() runs. + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + + // 3. Wire into a PluginManager and call initialize. The + // manager's initialize() drives plugin.initialize(), which + // triggers the async JWKS fetch. + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.expect("initialize succeeds"); + + // 4. Mint a JWT, dispatch, assert subject populated. + let token = mint_jwt( + &priv_pem, + json!({ + "sub": "alice@corp.com", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + "roles": ["hr"], + }), + ); + + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".to_string(), format!("Bearer {token}")); + + let payload = IdentityPayload::new(token.clone(), TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + + let (result, _bg) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!( + result.continue_processing, + "valid JWT (JWKS-resolved key) should pass: violation = {:?}", + result.violation + ); + let identity = + IdentityPayload::from_pipeline_result(&result).expect("identity payload present"); + let subject = identity.subject.as_ref().expect("subject populated"); + assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); + assert!(subject.roles.contains("hr")); + + // 5. The mock recorded one (and only one) GET — proves we did + // a real network fetch. + mock.assert_async().await; +} + +/// Without `initialize()`, the issuer config sits in `pending_jwks` +/// and `trusted_issuers` is empty — a token signed by the JWKS key +/// gets `auth.untrusted_issuer` rather than silently passing. This +/// is the deliberate fail-loud mode: hosts must call +/// `PluginManager::initialize()`. +#[tokio::test(flavor = "multi_thread")] +async fn skipping_initialize_rejects_with_untrusted_issuer() { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); + let pub_key = RsaPublicKey::from(&priv_key); + let priv_pem = priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(); + + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(build_jwks(&pub_key).to_string()) + // We expect ZERO calls — the test never calls initialize. + .expect(0) + .create_async() + .await; + + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + // Deliberately SKIP mgr.initialize() — we want to prove the + // pending JwksUrl issuer never made it into trusted_issuers. + + let token = mint_jwt( + &priv_pem, + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".to_string(), format!("Bearer {token}")); + + let payload = IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + let (result, _bg) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!( + !result.continue_processing, + "no initialize() should yield deny (JWKS issuer never wired)", + ); + let v = result.violation.expect("violation should be reported"); + assert_eq!(v.code, "auth.untrusted_issuer"); +} + +// ===================================================================== +// P0-5 Slice A: kid-based key selection + JWKS fetch timeout +// ===================================================================== + +/// Build a JWKS containing two RSA keys with distinct `kid`s. Used by +/// the rotation / kid-selection tests below to prove the resolver +/// picks the key matching the inbound token's header, not the first +/// listed. +fn build_jwks_two_keys( + pub_a: &RsaPublicKey, + kid_a: &str, + pub_b: &RsaPublicKey, + kid_b: &str, +) -> Value { + use base64::Engine; + let make_entry = |k: &RsaPublicKey, kid: &str| { + json!({ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(k.n().to_bytes_be()), + "e": base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(k.e().to_bytes_be()), + }) + }; + json!({ + "keys": [ + make_entry(pub_a, kid_a), + make_entry(pub_b, kid_b), + ] + }) +} + +/// Mint a JWT with a specific `kid` in the header. Distinct from +/// `mint_jwt` (which uses the default test kid) so the kid-selection +/// tests can control which key the resolver should select. +fn mint_jwt_with_kid(private_pem: &str, kid: &str, claims: Value) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.into()); + let key = EncodingKey::from_rsa_pem(private_pem.as_bytes()) + .expect("build EncodingKey from RSA PEM"); + encode(&header, &claims, &key).expect("sign JWT") +} + +/// JWKS publishes two keys with distinct kids. A token signed by +/// key B with header `kid=key-b` must validate against key B, not +/// against the first-listed key A. Pre-Slice-A code would pick the +/// first key (A) and reject the valid token as signature_invalid. +#[tokio::test(flavor = "multi_thread")] +async fn kid_selects_correct_key_when_jwks_has_multiple() { + let mut rng = rand::thread_rng(); + let priv_a = RsaPrivateKey::new(&mut rng, 2048).expect("rsa a"); + let priv_b = RsaPrivateKey::new(&mut rng, 2048).expect("rsa b"); + let pub_a = RsaPublicKey::from(&priv_a); + let pub_b = RsaPublicKey::from(&priv_b); + let priv_pem_b = priv_b + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM b") + .to_string(); + + let jwks_body = build_jwks_two_keys(&pub_a, "key-a", &pub_b, "key-b").to_string(); + + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(jwks_body) + .create_async() + .await; + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.expect("initialize"); + + // Token signed by B, with kid=key-b. The resolver must select + // key B from the JWKS (not first-listed key A). + let token = mint_jwt_with_kid( + &priv_pem_b, + "key-b", + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".into(), format!("Bearer {token}")); + let payload = IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + let (result, _) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!( + result.continue_processing, + "kid-matched token must verify: violation = {:?}", + result.violation, + ); +} + +/// Token's `kid` header doesn't match any key the JWKS knows about. +/// Must yield `auth.unknown_kid` — distinct from +/// `auth.signature_invalid` so operators can tell rotation lag +/// from forgery at the audit layer. +#[tokio::test(flavor = "multi_thread")] +async fn unknown_kid_yields_unknown_kid_violation() { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("rsa"); + let pub_key = RsaPublicKey::from(&priv_key); + let priv_pem = priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(); + + // JWKS publishes a single key with kid=test-key-1. + let jwks_body = build_jwks(&pub_key).to_string(); + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(jwks_body) + .create_async() + .await; + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.expect("initialize"); + + // Token signed by the right private key, but its header + // declares `kid=stale-key` — which is what the IdP would do + // post-rotation if we haven't refreshed yet. + let token = mint_jwt_with_kid( + &priv_pem, + "stale-key", + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".into(), format!("Bearer {token}")); + let payload = IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + let (result, _) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!(!result.continue_processing); + let v = result.violation.expect("violation reported"); + assert_eq!(v.code, "auth.unknown_kid"); + assert!( + v.reason.contains("stale-key"), + "reason should name the missing kid: {}", + v.reason, + ); +} + +/// JWKS endpoint accepts the TCP connection but stalls indefinitely +/// on the HTTP response — the kind of slow-loris pattern a hostile +/// or simply broken IdP could exhibit. The fetch must time out +/// rather than hanging `initialize()` forever. +#[tokio::test(flavor = "multi_thread")] +async fn jwks_fetch_times_out_when_endpoint_stalls() { + use std::time::Duration; + use tokio::io::AsyncWriteExt; + + // Stand up a tiny TCP listener that accepts connections, reads + // the request headers, and then deliberately never sends a + // response body. The JWKS fetch should give up after the + // configured timeout (~5s) rather than waiting forever. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral"); + let addr = listener.local_addr().expect("listener addr"); + tokio::spawn(async move { + while let Ok((mut sock, _)) = listener.accept().await { + tokio::spawn(async move { + // Drain a bit of request data, then send a partial + // status line and stop. Reqwest will sit waiting + // for body bytes that never arrive. + let mut buf = [0u8; 512]; + let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await; + let _ = sock + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n") + .await; + // Hold the connection open without writing the + // 100-byte body. Sleep beyond the resolver's + // overall timeout to confirm timeout-not-receive. + tokio::time::sleep(Duration::from_secs(15)).await; + }); + } + }); + + let url = format!("http://{addr}/jwks"); + let src = DecodingKeySource::JwksUrl { + url: url.clone(), + insecure_http: true, + refresh_secs: 3600, + }; + + let started = std::time::Instant::now(); + let outcome = src.build_async().await; + let elapsed = started.elapsed(); + + // The wall-clock bound is the load-bearing assertion: a slow + // / hostile JWKS must not hang `build_async` indefinitely. The + // exact error string reqwest surfaces for a deadline elapsed + // varies across platforms and reqwest versions — sometimes + // "timeout", sometimes "body read failed: error decoding + // response body" (when the body stream gets cut by the + // deadline). We accept any Err outcome and rely on elapsed + // time as the contract. + match outcome { + Err(_e) => {} + Ok(_store) => panic!("stalled JWKS must not produce a KeyStore"), + } + // 5s overall timeout + 2s margin for setup / scheduler jitter. + assert!( + elapsed < Duration::from_secs(8), + "fetch should have given up promptly; took {elapsed:?}", + ); +} + +// ===================================================================== +// P0-5 Slice B: soft-fail at boot + periodic JWKS refresh +// ===================================================================== + +/// JWKS endpoint is unreachable at gateway boot. The plugin must +/// `initialize()` cleanly (no Err — soft-fail) so the gateway +/// doesn't crash on a transient IdP outage. Subsequent verify +/// calls against tokens for that issuer must surface +/// `auth.jwks_unavailable` — a clear, distinct code so operators +/// see "JWKS issue at IdP X" rather than the alarming +/// `auth.signature_invalid` they'd see if we silently used an +/// empty key. +#[tokio::test(flavor = "multi_thread")] +async fn jwks_unreachable_at_initialize_soft_fails() { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("rsa"); + let priv_pem = priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(); + + // Point at 127.0.0.1:1 — port 1 isn't bound by typical systems, + // so the TCP connect fails fast. The fetch timeout would also + // catch a slow endpoint; here we just want "unreachable." + let jwks_url = "http://127.0.0.1:1/jwks".to_string(); + let cfg = resolver_config(&jwks_url); + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + + // The gateway boots — initialize returns Ok even though the + // JWKS fetch failed. This is the soft-fail invariant. + mgr.initialize().await.expect("initialize must NOT propagate JWKS failure"); + + // A token signed by the right key fails verify with + // `auth.jwks_unavailable` rather than crashing or returning + // the wrong code. The resolver's KeyStore is empty until + // refresh succeeds (which it won't, in this test). + let token = mint_jwt_with_kid( + &priv_pem, + "test-key-1", + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".into(), format!("Bearer {token}")); + let payload = IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers); + let (result, _) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + assert!(!result.continue_processing); + let v = result.violation.expect("violation reported"); + assert_eq!(v.code, "auth.jwks_unavailable"); + assert!( + v.reason.contains(ISS), + "reason should name the affected issuer: {}", + v.reason, + ); +} + +/// Initial JWKS publishes key A; the mock then rotates to key B. +/// A token signed by B with `kid=key-b` is initially rejected +/// (KeyStore only knows A). After the refresh interval ticks, +/// the resolver's KeyStore swaps in B and the same token +/// validates. Pins both: +/// - that refresh runs without restart +/// - that whole-store replacement actually swaps (not merges, +/// not silently drops the update) +#[tokio::test(flavor = "multi_thread")] +async fn jwks_refresh_picks_up_rotated_key() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + let mut rng = rand::thread_rng(); + let priv_a = RsaPrivateKey::new(&mut rng, 2048).expect("rsa a"); + let priv_b = RsaPrivateKey::new(&mut rng, 2048).expect("rsa b"); + let pub_a = RsaPublicKey::from(&priv_a); + let pub_b = RsaPublicKey::from(&priv_b); + let priv_pem_b = priv_b + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM b") + .to_string(); + + let jwks_a = build_jwks(&pub_a).to_string(); + let jwks_b = { + use base64::Engine; + json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "key-b", + "n": base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(pub_b.n().to_bytes_be()), + "e": base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(pub_b.e().to_bytes_be()), + }] + }) + .to_string() + }; + + // Track how many times the JWKS endpoint has been hit so we + // can flip the response body after the first fetch. + let fetch_count = Arc::new(AtomicUsize::new(0)); + + let mut server = Server::new_async().await; + let count_for_mock = Arc::clone(&fetch_count); + let jwks_b_clone = jwks_b.clone(); + let _mock = server + .mock("GET", "/realms/test/protocol/openid-connect/certs") + .with_status(200) + .with_header("content-type", "application/json") + .with_body_from_request(move |_req| { + let n = count_for_mock.fetch_add(1, Ordering::SeqCst); + if n == 0 { + jwks_a.clone().into_bytes() + } else { + jwks_b_clone.clone().into_bytes() + } + }) + .expect_at_least(2) + .create_async() + .await; + let jwks_url = format!("{}/realms/test/protocol/openid-connect/certs", server.url()); + + // Resolver config with a short refresh — 1 second keeps the + // test wall-clock low. The default 600s wouldn't fire inside + // the test window. Built inline rather than via + // `resolver_config(...)` because we need the `refresh_secs` + // field which the shared helper doesn't expose. + let cfg = PluginConfig { + name: "jwt-via-jwks".into(), + kind: "test".into(), + hooks: vec![HOOK_IDENTITY_RESOLVE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(json!({ + "role": "user", + "header": "Authorization", + "trusted_issuers": [{ + "issuer": ISS, + "audiences": [AUD], + "algorithms": ["RS256"], + "decoding_key": { + "kind": "jwks_url", + "url": jwks_url, + "insecure_http": true, + "refresh_secs": 1, + }, + "leeway_seconds": 60, + }], + "claim_mapper": "standard", + })), + ..Default::default() + }; + + let resolver = Arc::new(JwtIdentityResolver::new(cfg.clone()).expect("constructs")); + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::clone(&resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.expect("initialize"); + + // Token signed by B, with kid=key-b. Pre-refresh, the + // resolver only knows key A → `auth.unknown_kid`. + let make_payload = || { + let token = mint_jwt_with_kid( + &priv_pem_b, + "key-b", + json!({ + "sub": "alice", + "iss": ISS, + "aud": AUD, + "exp": now_unix() + 300, + "iat": now_unix(), + }), + ); + let mut headers = std::collections::HashMap::new(); + headers.insert("Authorization".into(), format!("Bearer {token}")); + IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers) + }; + + let (pre, _) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + make_payload(), + Extensions::default(), + None, + ) + .await; + assert!(!pre.continue_processing, "key-b token should not validate before refresh"); + assert_eq!( + pre.violation.expect("violation").code, + "auth.unknown_kid", + "pre-refresh: kid mismatch should report unknown_kid", + ); + + // Wait long enough for the refresh task to fire at least once. + // 1s refresh interval + a generous margin for scheduler jitter. + // Poll the same verify in a loop until it succeeds or we time + // out — avoids a flaky fixed sleep. + let deadline = std::time::Instant::now() + Duration::from_secs(8); + let mut succeeded = false; + while std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(200)).await; + let (r, _) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + make_payload(), + Extensions::default(), + None, + ) + .await; + if r.continue_processing { + succeeded = true; + break; + } + } + assert!( + succeeded, + "refresh task should have swapped in key-b within 8s of a 1s-interval refresh", + ); +} diff --git a/crates/apl-identity-jwt/tests/jwt_e2e.rs b/crates/apl-identity-jwt/tests/jwt_e2e.rs new file mode 100644 index 00000000..b18a6c3c --- /dev/null +++ b/crates/apl-identity-jwt/tests/jwt_e2e.rs @@ -0,0 +1,298 @@ +// Location: ./crates/apl-identity-jwt/tests/jwt_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end tests for `JwtIdentityResolver` against a real RSA +// keypair + signed JWTs. Exercises the full handler path: +// `mgr.invoke_named::(...)` → resolver decodes / +// validates / maps claims → host extracts the populated +// `IdentityPayload` via `from_pipeline_result`. +// +// Scenarios: +// * happy path: valid signed token resolves to a populated subject +// * untrusted issuer (token signed correctly but `iss` not in config) +// * expired token (`exp` in the past) +// * audience mismatch +// * signature tamper +// +// Keypair is generated once per test process (RSA 2048 takes +// ~50-100ms; one-time cost) and shared across tests via OnceLock. + +use std::sync::Arc; +use std::sync::OnceLock; + +use cpex_core::extensions::raw_credentials::{TokenKind, TokenRole}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use apl_identity_jwt::JwtIdentityResolver; + +use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; +use rsa::{RsaPrivateKey, RsaPublicKey}; + +use serde_json::{json, Value}; + +const TEST_ISSUER: &str = "https://idp.test.local"; +const TEST_AUDIENCE: &str = "test-api"; + +// ===================================================================== +// Test fixtures +// ===================================================================== + +struct Keypair { + private_pem: String, + public_pem: String, +} + +/// Process-global keypair. Generated once on first access; RSA 2048 +/// is ~50-100ms which we don't want to pay per-test. +fn keypair() -> &'static Keypair { + static KP: OnceLock = OnceLock::new(); + KP.get_or_init(|| { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); + let pub_key = RsaPublicKey::from(&priv_key); + Keypair { + private_pem: priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(), + public_pem: pub_key + .to_public_key_pem(LineEnding::LF) + .expect("encode public PEM"), + } + }) +} + +/// Sign `claims` as an RS256 JWT using the test private key. JWT +/// payload is whatever JSON the caller hands in. +fn mint_jwt(claims: Value) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let header = Header::new(Algorithm::RS256); + let key = EncodingKey::from_rsa_pem(keypair().private_pem.as_bytes()) + .expect("build EncodingKey from test private PEM"); + encode(&header, &claims, &key).expect("sign JWT") +} + +/// Construct a `PluginConfig` whose `config:` block declares the +/// test public key as the trusted-issuer signing material. Mirrors +/// what an operator writes in unified-config YAML. +fn resolver_plugin_config() -> PluginConfig { + let plugin_config = json!({ + "trusted_issuers": [{ + "issuer": TEST_ISSUER, + "audiences": [TEST_AUDIENCE], + "algorithms": ["RS256"], + "decoding_key": { + "kind": "pem", + "pem": keypair().public_pem, + }, + "leeway_seconds": 60, + }], + "claim_mapper": "standard", + }); + PluginConfig { + name: "jwt-resolver".into(), + kind: "test".into(), + hooks: vec![HOOK_IDENTITY_RESOLVE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(plugin_config), + ..Default::default() + } +} + +/// Build the PluginManager + register the resolver + initialize. +/// All four scenarios share this skeleton. +async fn build_manager() -> Arc { + let cfg = resolver_plugin_config(); + let resolver = JwtIdentityResolver::new(cfg.clone()).expect("resolver should construct"); + + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler_for_names::( + Arc::new(resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +/// Run a token through the full handler pipeline. +async fn invoke(token: String) -> cpex_core::executor::PipelineResult { + let mgr = build_manager().await; + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + IdentityPayload::new(token, TokenSource::Bearer), + Extensions::default(), + None, + ) + .await; + result +} + +fn now_unix() -> i64 { + chrono::Utc::now().timestamp() +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Happy path: valid signed token resolves to a populated subject, +/// raw token lands in `raw_credentials.inbound_tokens[User]`. +#[tokio::test] +async fn valid_jwt_resolves_subject() { + let token = mint_jwt(json!({ + "sub": "alice@corp.com", + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + "iat": now_unix(), + "roles": ["hr", "reader"], + "email": "alice@corp.com", + })); + + let result = invoke(token.clone()).await; + assert!( + result.continue_processing, + "valid token should resolve: violation = {:?}", + result.violation, + ); + + let identity = IdentityPayload::from_pipeline_result(&result) + .expect("payload should be present"); + let subject = identity.subject.as_ref().expect("subject populated"); + assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); + assert!(subject.roles.contains("hr")); + assert!(subject.roles.contains("reader")); + // `email` was not a reserved claim, lands under subject.claims + assert_eq!( + subject.claims.get("email"), + Some(&"alice@corp.com".to_string()), + ); + + // Raw token stashed for forwarding plugins. + let raw = identity + .raw_credentials + .as_ref() + .expect("raw_credentials populated"); + let user_token = raw + .inbound_tokens + .get(&TokenRole::User) + .expect("user-role token present"); + assert_eq!(&*user_token.token, &token); + assert!(matches!(user_token.kind, TokenKind::Jwt)); +} + +/// Token correctly signed by the test key but its `iss` doesn't +/// match any trusted issuer in our config → `auth.untrusted_issuer`. +/// This is the path where the peek-at-iss step does its job. +#[tokio::test] +async fn untrusted_issuer_rejects() { + let token = mint_jwt(json!({ + "sub": "alice", + "iss": "https://hacker.example.com", // not in trusted_issuers list + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + })); + + let result = invoke(token).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.untrusted_issuer"); +} + +/// `exp` claim is one hour in the past → `auth.token_expired`. +/// Leeway is 60s so a 1h-stale token is unambiguously rejected. +#[tokio::test] +async fn expired_token_rejects() { + let token = mint_jwt(json!({ + "sub": "alice", + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() - 3600, + })); + + let result = invoke(token).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.token_expired"); +} + +/// `aud` doesn't match the configured audience → `auth.audience_mismatch`. +#[tokio::test] +async fn wrong_audience_rejects() { + let token = mint_jwt(json!({ + "sub": "alice", + "iss": TEST_ISSUER, + "aud": "some-other-api", // not the configured TEST_AUDIENCE + "exp": now_unix() + 300, + })); + + let result = invoke(token).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.audience_mismatch"); +} + +/// Tamper with the signature bytes → signature verification fails → +/// `auth.signature_invalid`. The load-bearing test for the security +/// story; if this passes, the cryptographic validation is wired +/// correctly through the whole pipeline. +#[tokio::test] +async fn tampered_signature_rejects() { + let valid = mint_jwt(json!({ + "sub": "alice", + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + })); + // Flip a char in the middle of the signature segment. We + // can't tamper with the *last* char because base64url + // encoding of a 256-byte RSA-2048 signature requires its last + // char to encode 4 trailing-bit zeros — only `{A, Q, g, w}` + // satisfy that. A naive flip to an out-of-set char produces + // invalid base64 (decoder error → `auth.malformed_header`) + // rather than valid bytes that fail signature verification. + // Middle-segment chars don't have the trailing-bit constraint. + let parts: Vec<&str> = valid.split('.').collect(); + assert_eq!(parts.len(), 3, "JWT should have three segments"); + let sig = parts[2]; + let mut sig_chars: Vec = sig.chars().collect(); + let target_idx = sig_chars.len() / 2; // well into the middle + let original = sig_chars[target_idx]; + // Pick a replacement that's different but in the same charset. + let replacement = if original == 'A' { 'B' } else { 'A' }; + sig_chars[target_idx] = replacement; + let new_sig: String = sig_chars.into_iter().collect(); + let tampered = format!("{}.{}.{}", parts[0], parts[1], new_sig); + + let result = invoke(tampered).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.signature_invalid"); +} + +/// Token with no `iss` claim at all → `auth.malformed_header` from +/// the peek step (we can't pick a trusted issuer without `iss`). +#[tokio::test] +async fn missing_iss_rejects() { + let token = mint_jwt(json!({ + "sub": "alice", + // no iss + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + })); + + let result = invoke(token).await; + assert!(!result.continue_processing); + let v = result.violation.expect("rejection should surface"); + assert_eq!(v.code, "auth.malformed_header"); +} diff --git a/crates/apl-pdp-cedar-direct/Cargo.toml b/crates/apl-pdp-cedar-direct/Cargo.toml new file mode 100644 index 00000000..4072a665 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/Cargo.toml @@ -0,0 +1,63 @@ +# Location: ./crates/apl-pdp-cedar-direct/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-pdp-cedar-direct — a `PdpResolver` implementation that wraps the bare +# `cedar-policy` crate (Amazon's Cedar engine, no JWT validation, no policy +# store loading, no Lock Server integration). +# +# When to use this crate vs `apl-pdp-cedarling`: +# +# - **cedar-direct** — host already has identity validated (via gateway, +# SPIFFE, prior plugin, or hand-rolled JWT validation); policies are +# loaded as text/files at startup and don't change at runtime; smallest +# dep tree; ~5 transitive crates instead of 200+. +# - **cedarling** — host wants JWT validation + claims-to-entity mapping +# + centralized policy management (Janssen Lock Server) all in one +# library. +# +# Both crates speak Cedar 4.x; their decisions on identical policy + entity +# + request inputs are byte-identical. The difference is what's around the +# Cedar engine. + +[package] +name = "apl-pdp-cedar-direct" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +# Permissive caret spec — `"4"` means "any 4.x that Cargo can find." +# We rely on Cargo's standard version resolution to dedup with +# cedarling's `cedar-policy = "4.9.0"` (also caret), so both crates +# end up compiling against the same `cedar-policy` version (currently +# 4.11 — bumps automatically when either side allows a newer 4.x). +# This matters because mixing `cedar_policy@4.9::Decision` and +# `cedar_policy@4.11::Decision` in the same workspace would produce +# distinct types Rust treats as incompatible. +# +# Code-side note: we use `Request::new(...)` (added in 4.11 alongside +# the deprecated builder; still available in older 4.x via the +# constructor form). Tracked separately if we ever need to support +# pre-4.x or post-5.x. +cedar-policy = "4" +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +# End-to-end integration tests wire the cedar-direct factory through the +# apl-cpex visitor and exercise it against a real `PluginManager`. These +# dev-dep edges only exist for tests — the crate itself stays +# apl-core-only at compile time so it can be used standalone (e.g. in a +# custom orchestrator that doesn't go through apl-cpex at all). +apl-cmf = { path = "../apl-cmf" } +apl-cpex = { path = "../apl-cpex" } +cpex-core = { path = "../cpex-core" } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs b/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs new file mode 100644 index 00000000..ad91dd76 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/cedar_attrs.rs @@ -0,0 +1,61 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/cedar_attrs.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Canonical Cedar entity attribute names. +// +// Cedar policy authors write `principal.roles.contains("hr")`, +// `principal.permissions.contains("view_ssn")`, etc. — the strings on +// the right side of `principal.` are *Cedar entity attribute names* +// that this crate produces when it builds the principal entity from the +// `AttributeBag`. Author-facing vocabulary, distinct from the +// `apl-cmf::constants::BAG_*` bag-key vocabulary even when the words +// happen to match. +// +// Keeping these constants in one module means a rename ripples to a +// single file. The entity builder in `entities.rs` and any future +// schema generator both reference them by symbol. +// +// Pair this list with the schema published to Cedar authors — every +// constant here should appear in any official entity schema. + +/// `id` — the entity's identifier attribute (we emit it inside `attrs` +/// for legibility even though Cedar also has it in the `uid` slot). +pub const ATTR_ID: &str = "id"; + +/// `type` — the entity's type name as a string, for policies that +/// branch on subject kind (`principal.type == "agent"` etc.). +pub const ATTR_TYPE: &str = "type"; + +/// `roles` — `Set` of role names the principal holds. +/// Filled from `apl-cmf`'s `role.*` bag keys. +pub const ATTR_ROLES: &str = "roles"; + +/// `permissions` — `Set` of permission names. +/// Filled from `apl-cmf`'s `perm.*` bag keys. +pub const ATTR_PERMISSIONS: &str = "permissions"; + +/// `teams` — `Set` of team / group memberships. +/// Filled from `apl-cmf`'s `subject.teams` bag key. +pub const ATTR_TEAMS: &str = "teams"; + +/// `claims` — `Record` of arbitrary JWT-style claims. Filled from +/// `apl-cmf`'s `claim.*` bag keys. +pub const ATTR_CLAIMS: &str = "claims"; + +// ----- JSON wrapping keys (Cedar's entity-from-JSON shape) --------- +// +// These aren't entity attributes per se — they're the top-level +// keys of the JSON shape Cedar expects when reading an entity from +// `Entity::from_json_value`. Kept here so the entity-builder code +// stays free of magic strings. + +/// `uid` — the {type, id} envelope at the top of an entity JSON. +pub const KEY_UID: &str = "uid"; + +/// `attrs` — the attribute bag inside an entity JSON. +pub const KEY_ATTRS: &str = "attrs"; + +/// `parents` — the optional parents list inside an entity JSON. +pub const KEY_PARENTS: &str = "parents"; diff --git a/crates/apl-pdp-cedar-direct/src/decision.rs b/crates/apl-pdp-cedar-direct/src/decision.rs new file mode 100644 index 00000000..4391f98c --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/decision.rs @@ -0,0 +1,127 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/decision.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Translation from `cedar_policy::Response` into `apl_core::PdpDecision`. +// +// What we preserve: +// +// - `decision` — Allow ↔ Deny. One-to-one. +// - `diagnostics` — the set of policy IDs that *determined* the +// decision (not "matched" — Cedar's `reason()` is +// the policies whose effect produced the outcome). +// Operators who annotated their policies with +// `@id("...")` get meaningful identifiers; without +// annotations they get `policy0`, `policy1`, …. +// - `rule_source` — first policy ID from `diagnostics`. Becomes the +// violation code on Deny so audit logs / wire +// errors say "denied via owner-override" rather +// than "cedar.deny." +// +// What we drop (for now): +// +// - Obligations — Cedar 4.10 doesn't have first-class obligations. +// Policy annotations could carry them (`@obligation(...)`) but +// wiring the annotation vocabulary is deferred — see +// `docs/specs/cedar-context-contract.md`. +// +// # Fail-closed on evaluation errors +// +// Cedar's `Response::diagnostics().errors()` lists policies that errored +// during runtime evaluation (e.g. type errors in a `when` clause that +// only manifest with certain entity data). If ANY policy errored, we +// return Deny regardless of what `decision()` says — an untrusted +// decision is worse than a closed gate. The error messages flow into +// the Deny reason so operators see why. + +use apl_core::evaluator::Decision; +use apl_core::step::PdpDecision; +use cedar_policy::{Decision as CedarDecision, PolicySet}; + +/// Translate a `cedar_policy::Response` into the APL-side `PdpDecision`. +/// Captures policy-ID attribution into `diagnostics` and, on Deny, +/// surfaces the first firing policy as the `rule_source`. +/// +/// # `@id` annotation lookup +/// +/// `PolicySet::from_str` assigns auto-IDs (`policy0`, `policy1`, ...); +/// authors get *meaningful* identifiers by annotating each policy with +/// `@id("my-rule")`. We resolve auto-IDs to annotation values here so +/// the rest of the system sees the names operators chose. Policies +/// without `@id` annotations keep their auto-IDs — explicit-is-better +/// fallback rather than silent translation. +pub fn translate(response: &cedar_policy::Response, policy_set: &PolicySet) -> PdpDecision { + let diagnostics = response.diagnostics(); + + let firing_policies: Vec = diagnostics + .reason() + .map(|pid| { + // Prefer the operator-supplied `@id("...")` annotation; + // fall back to Cedar's auto-generated id when the policy + // is unannotated. + policy_set + .policy(pid) + .and_then(|p| p.annotation("id")) + .map(|s| s.to_string()) + .unwrap_or_else(|| pid.to_string()) + }) + .collect(); + + let errors: Vec = diagnostics + .errors() + .map(|e| e.to_string()) + .collect(); + + // Fail-closed: any runtime evaluation error → Deny with the error + // text so the operator sees what went wrong. Cedar's own + // `decision()` may still say Allow when errors occurred; we override + // because an Allow on a partially-failed evaluation isn't + // trustworthy. + if !errors.is_empty() { + let reason = format!( + "Cedar evaluation produced errors (fail-closed): {}", + errors.join("; ") + ); + let rule_source = firing_policies + .first() + .cloned() + .unwrap_or_else(|| "cedar.evaluation_error".to_string()); + return PdpDecision { + decision: Decision::Deny { + reason: Some(reason), + rule_source, + }, + diagnostics: firing_policies, + }; + } + + let decision = match response.decision() { + CedarDecision::Allow => Decision::Allow, + CedarDecision::Deny => { + // Build a human-readable reason from the firing policies so + // wire errors and audit logs carry attribution. First + // policy ID becomes the violation code. + let reason = if firing_policies.is_empty() { + // Cedar deny with no firing policy means no `permit` + // matched — the "default deny" case. + "no Cedar permit policy matched the request".to_string() + } else { + format!("denied by Cedar policy: {}", firing_policies.join(", ")) + }; + let rule_source = firing_policies + .first() + .cloned() + .unwrap_or_else(|| "cedar.default_deny".to_string()); + Decision::Deny { + reason: Some(reason), + rule_source, + } + } + }; + + PdpDecision { + decision, + diagnostics: firing_policies, + } +} diff --git a/crates/apl-pdp-cedar-direct/src/entities.rs b/crates/apl-pdp-cedar-direct/src/entities.rs new file mode 100644 index 00000000..3ae3cfcc --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/entities.rs @@ -0,0 +1,253 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/entities.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build a `cedar_policy::Entities` set from: +// +// - The `AttributeBag` — APL's view of `SecurityExtension` etc. +// populated upstream by apl-cmf. Source of the **principal** entity. +// - `PdpCall.args.resource` — the resource description the policy +// author wrote in the `cedar:(...)` step. Source of the **resource** +// entity. +// +// v0 builds a minimum-viable entity set: just principal + resource, +// no hierarchy (no `User in Team`, no `Document in Folder`). Operators +// who need that plug an `EntityProvider` trait we'll add later — when +// there's a real use case driving the design. +// +// # Why JSON-shaped construction +// +// Cedar's `Entity::from_json_value(json, schema)` accepts a record +// with `uid`, `attrs`, `parents` keys. We build that record from the +// bag / args and let Cedar's parser handle the attribute-value +// translation (string → String, JSON array of strings → Set, +// nested object → Record, etc.). Avoids fighting with +// `RestrictedExpression` directly. + +use std::collections::HashSet; + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use apl_core::step::PdpError; +use cedar_policy::{Entities, Entity, Schema}; +use serde_json::{json, Map, Value}; + +use crate::cedar_attrs::{ + ATTR_CLAIMS, ATTR_ID, ATTR_PERMISSIONS, ATTR_ROLES, ATTR_TEAMS, ATTR_TYPE, KEY_ATTRS, + KEY_PARENTS, KEY_UID, +}; + +/// Build the entity set for one Cedar request. Returns owned +/// `Entities` (Cedar takes them by reference at authorization time). +pub fn build( + bag: &AttributeBag, + resource_args: &serde_yaml::Value, + schema: Option<&Schema>, + entity_namespace: Option<&str>, +) -> Result { + let principal = build_principal(bag, schema, entity_namespace)?; + let resource = build_resource(resource_args, schema)?; + Entities::from_entities([principal, resource], schema).map_err(|e| { + PdpError::Dispatch(format!("failed to assemble Cedar entity set: {}", e)) + }) +} + +/// Build the principal `Entity` from the bag. Reads: +/// +/// - `subject.id` → entity id (required) +/// - `subject.type` → entity type ("User" | "Agent" | "Service" | +/// "System"); defaults to "User" when absent +/// - `role.=true` → `attrs.roles : Set` +/// - `perm.=true` → `attrs.permissions : Set` +/// - `claim.=v` → `attrs.claims.` (record) +/// - `subject.teams` → `attrs.teams : Set` +/// +/// Operators with custom claim attributes write their Cedar policies +/// against `principal.claims.foo` — those land via the `claim.foo` bag +/// key, populated upstream by apl-cmf from `SubjectExtension.claims`. +pub fn build_principal( + bag: &AttributeBag, + schema: Option<&Schema>, + entity_namespace: Option<&str>, +) -> Result { + let id = bag + .get_string("subject.id") + .ok_or_else(|| { + PdpError::Dispatch( + "Cedar request needs a principal but bag has no `subject.id` — \ + install an identity-hook plugin upstream of APL policy" + .to_string(), + ) + })? + .to_string(); + + let kind = bag.get_string("subject.type").unwrap_or("User"); + let entity_type = qualify_type(kind, entity_namespace); + + // Collect attributes from the bag. We pick the well-known shapes; + // arbitrary `subject.*` keys beyond these are intentionally NOT + // surfaced — operators with custom shapes use `claim.*` or extend + // the bridge. + // + // Empty defaults matter: Cedar's strict-evaluation mode raises a + // runtime error when a policy probes a missing attribute + // (`principal.roles.contains(...)` against a principal without + // `roles`). The resolver's fail-closed logic would then deny — + // surprising for policy authors who expect missing-attribute → + // empty-set semantics. Populating empty sets / records by default + // gives clean "attribute exists, just empty" behavior. + let mut attrs = Map::new(); + attrs.insert(ATTR_ID.to_string(), json!(id)); + attrs.insert(ATTR_TYPE.to_string(), json!(kind)); + + // TODO(vocab consolidation, Phase C): `"role."`, `"perm."`, and + // `"subject.teams"` are apl-cmf bag-key conventions. The cedar + // crate would need a dependency on apl-cmf (or the BAG_* constants + // need to move into apl-core / a shared crate) before we can + // reference them by symbol here. Left literal for now — the gap is + // tracked in the `project_vocab_consolidation` memory. + let roles = collect_prefixed_bools(bag, "role."); + attrs.insert(ATTR_ROLES.to_string(), json!(roles)); + + let permissions = collect_prefixed_bools(bag, "perm."); + attrs.insert(ATTR_PERMISSIONS.to_string(), json!(permissions)); + + let teams: Vec = bag + .get_string_set("subject.teams") + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + attrs.insert(ATTR_TEAMS.to_string(), json!(teams)); + + let claims = collect_claims(bag); + attrs.insert(ATTR_CLAIMS.to_string(), Value::Object(claims)); + + let mut uid_obj = Map::new(); + uid_obj.insert(ATTR_TYPE.to_string(), json!(entity_type)); + uid_obj.insert(ATTR_ID.to_string(), json!(id)); + let mut entity_obj = Map::new(); + entity_obj.insert(KEY_UID.to_string(), Value::Object(uid_obj)); + entity_obj.insert(KEY_ATTRS.to_string(), Value::Object(attrs)); + entity_obj.insert(KEY_PARENTS.to_string(), Value::Array(vec![])); + let entity_json = Value::Object(entity_obj); + + Entity::from_json_value(entity_json, schema).map_err(|e| { + PdpError::Dispatch(format!( + "failed to construct principal entity '{}::\"{}\"': {}", + entity_type, id, e + )) + }) +} + +/// Build the resource `Entity` from the policy author's `args.resource` +/// block. Shape: +/// +/// ```yaml +/// resource: +/// type: Document # required, Cedar entity type +/// id: doc-42 # required, entity id (string) +/// attributes: # optional, key → JSON value +/// classification: internal +/// owner: 'User::"alice"' +/// ``` +pub fn build_resource( + resource_args: &serde_yaml::Value, + schema: Option<&Schema>, +) -> Result { + let map = resource_args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedar:() `resource` must be a mapping with `type` and `id` keys".to_string(), + ) + })?; + + let entity_type = yaml_string(map, "type").ok_or_else(|| { + PdpError::Dispatch("cedar:() `resource.type` missing or not a string".to_string()) + })?; + let id = yaml_string(map, "id").ok_or_else(|| { + PdpError::Dispatch("cedar:() `resource.id` missing or not a string".to_string()) + })?; + + let attrs_value = map + .get(serde_yaml::Value::String("attributes".to_string())) + .cloned() + .unwrap_or(serde_yaml::Value::Mapping(Default::default())); + let attrs_json: Value = serde_json::to_value(&attrs_value).map_err(|e| { + PdpError::Dispatch(format!( + "cedar:() `resource.attributes` not JSON-representable: {}", + e + )) + })?; + + let mut uid_obj = Map::new(); + uid_obj.insert(ATTR_TYPE.to_string(), json!(entity_type)); + uid_obj.insert(ATTR_ID.to_string(), json!(id)); + let mut entity_obj = Map::new(); + entity_obj.insert(KEY_UID.to_string(), Value::Object(uid_obj)); + entity_obj.insert(KEY_ATTRS.to_string(), attrs_json); + entity_obj.insert(KEY_PARENTS.to_string(), Value::Array(vec![])); + let entity_json = Value::Object(entity_obj); + + Entity::from_json_value(entity_json, schema).map_err(|e| { + PdpError::Dispatch(format!( + "failed to construct resource entity '{}::\"{}\"': {}", + entity_type, id, e + )) + }) +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Apply the optional namespace to a bare entity type. `Some("Acme")` + +/// `"User"` → `"Acme::User"`. `None` → `"User"`. Lets operators with +/// namespaced schemas (`Acme::User`, `Acme::Document`) work without +/// each policy author having to hand-prefix everywhere. +fn qualify_type(bare: &str, namespace: Option<&str>) -> String { + match namespace { + Some(ns) if !ns.is_empty() => format!("{}::{}", ns, bare), + _ => bare.to_string(), + } +} + +/// Read every `X = true` key from the bag and return `[X, ...]`. +/// Used for `role.*` → roles and `perm.*` → permissions, matching +/// apl-cmf's presence-only encoding for role / permission membership. +fn collect_prefixed_bools(bag: &AttributeBag, prefix: &str) -> Vec { + let mut out: HashSet = HashSet::new(); + for (key, value) in bag.iter() { + if let Some(name) = key.strip_prefix(prefix) { + if matches!(value, AttributeValue::Bool(true)) { + out.insert(name.to_string()); + } + } + } + let mut v: Vec = out.into_iter().collect(); + v.sort(); + v +} + +/// Read every `claim.` key and assemble a JSON record of the +/// values. Each claim's value type comes through as JSON (`Bool`, +/// `String`, etc.) so Cedar's record-of-records story works. +fn collect_claims(bag: &AttributeBag) -> Map { + let mut out = Map::new(); + for (key, value) in bag.iter() { + if let Some(name) = key.strip_prefix("claim.") { + let v = match value { + AttributeValue::Bool(b) => json!(*b), + AttributeValue::Int(i) => json!(*i), + AttributeValue::Float(f) => json!(*f), + AttributeValue::String(s) => json!(s), + AttributeValue::StringSet(set) => json!(set.iter().collect::>()), + }; + out.insert(name.to_string(), v); + } + } + out +} + +fn yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_string()))? + .as_str() + .map(|s| s.to_string()) +} diff --git a/crates/apl-pdp-cedar-direct/src/error.rs b/crates/apl-pdp-cedar-direct/src/error.rs new file mode 100644 index 00000000..3b640fc2 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/error.rs @@ -0,0 +1,67 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/error.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build-time errors for `CedarDirectResolver`. All variants fire at +// construction (parse, validate, load); never at request time. +// +// Request-time errors flow through `apl_core::PdpError` because that's +// the trait's return type. The two error stories are deliberately +// separate — build errors are config faults the operator fixes once; +// request errors are per-evaluation issues the host has to handle +// continuously. +// +// `BuildError` implements `std::error::Error` (via thiserror), so it +// boxes cleanly into `apl_cpex::visitor::VisitorError` when the +// AplConfigVisitor builds a resolver from a unified-config block. The +// visitor then wraps that into `cpex_core::PluginError::Config` on its +// way out of `load_config_yaml`. Each layer wraps the layer below using +// its own native error type — no dep inversion required to make the +// error flow work. + +use thiserror::Error; + +/// Error returned at resolver construction. +#[derive(Debug, Error)] +pub enum BuildError { + /// The policy text didn't parse as Cedar. Carries the underlying + /// parser message verbatim so operators can see exactly which + /// `permit`/`forbid` line broke. + #[error("failed to parse Cedar policy set: {0}")] + PolicyParse(String), + + /// Cedar accepted the policy text but the schema (if supplied) + /// rejected one or more policies as invalid against the declared + /// entity / action shape. + #[error("policy set failed schema validation: {0}")] + PolicyValidation(String), + + /// I/O failure reading a policy file from disk. Distinct variant + /// from `PolicyParse` so operators can tell "file not found" from + /// "file found but unparseable" without grepping the message. + #[error("failed to read Cedar policy file '{path}': {source}")] + PolicyFile { + path: String, + #[source] + source: std::io::Error, + }, + + /// Schema text didn't parse as Cedar schema. + #[error("failed to parse Cedar schema: {0}")] + SchemaParse(String), + + /// I/O failure reading a schema file from disk. + #[error("failed to read Cedar schema file '{path}': {source}")] + SchemaFile { + path: String, + #[source] + source: std::io::Error, + }, + + /// Config block missing required fields, or fields had the wrong + /// shape. Fired by `from_config(&serde_yaml::Value)` when the + /// operator's YAML doesn't match the expected layout. + #[error("invalid Cedar PDP config: {0}")] + ConfigShape(String), +} diff --git a/crates/apl-pdp-cedar-direct/src/factory.rs b/crates/apl-pdp-cedar-direct/src/factory.rs new file mode 100644 index 00000000..dd5c4ba3 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/factory.rs @@ -0,0 +1,54 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CedarDirectPdpFactory` — the `PdpFactory` implementation that lets +// the apl-cpex visitor instantiate `CedarDirectResolver` from a +// unified-config YAML block: +// +// ```yaml +// global: +// apl: +// pdp: +// - kind: cedar-direct +// dialect: cedar # optional, defaults to PdpDialect::Cedar +// policy_text: | # required (or policy_file) +// @id("owner-override") +// permit(...); +// ``` +// +// Hosts register an instance of this factory in `AplOptions.pdp_factories`; +// the visitor matches it to the block by `kind` and dispatches. + +use std::sync::Arc; + +use apl_core::step::{PdpFactory, PdpResolver}; + +use crate::resolver::CedarDirectResolver; + +/// Factory for `CedarDirectResolver`. Reports `kind() = "cedar-direct"`; +/// builds resolvers from the unified-config block via +/// [`CedarDirectResolver::from_config`]. +#[derive(Default)] +pub struct CedarDirectPdpFactory; + +impl CedarDirectPdpFactory { + pub fn new() -> Self { + Self + } +} + +impl PdpFactory for CedarDirectPdpFactory { + fn kind(&self) -> &str { + "cedar-direct" + } + + fn build( + &self, + config: &serde_yaml::Value, + ) -> Result, Box> { + let resolver = CedarDirectResolver::from_config(config)?; + Ok(Arc::new(resolver)) + } +} diff --git a/crates/apl-pdp-cedar-direct/src/lib.rs b/crates/apl-pdp-cedar-direct/src/lib.rs new file mode 100644 index 00000000..606576aa --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/lib.rs @@ -0,0 +1,114 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-pdp-cedar-direct — `PdpResolver` over the bare `cedar-policy` crate. +// +// # Where this lives in the stack +// +// APL evaluator (apl-core) +// │ `cedar:(action:..., resource:..., context:...)` step +// ▼ +// PdpRouter (apl-cpex) — dispatches by dialect +// │ resolver.evaluate(call, bag) +// ▼ +// CedarDirectResolver — THIS CRATE +// │ translate to cedar_policy::Request + Entities +// ▼ +// cedar_policy::Authorizer — Amazon's official Cedar evaluator +// +// # Inputs (`PdpCall.args`) +// +// APL routes call cedar like: +// +// ```yaml +// policy: +// - cedar: +// action: 'Action::"read"' +// resource: +// type: Document +// id: doc-42 +// attributes: +// classification: internal +// owner: 'User::"alice"' +// context: +// request_time: "2026-05-18T10:00:00Z" +// ``` +// +// Required keys: `action`, `resource.type`, `resource.id`. Optional: +// `resource.attributes`, `context`. Principal is NOT in `args` — see +// below. +// +// # Principal +// +// The principal entity is built from the `AttributeBag` that apl-cmf +// populated from `SecurityExtension.subject`: +// +// - `subject.id` → entity id (required; missing → request-time error) +// - `subject.type` → entity type ("User", "Agent", "Service", "System"); +// defaults to "User" when absent +// - `role.=true` → principal.roles : Set +// - `perm.=true` → principal.permissions : Set +// - `claim.=v` → principal.claims. = v +// - `subject.teams` → principal.teams : Set +// - `subject.id` → principal.id : String +// +// Operators with richer principal shapes (custom JWT claims, workload +// trust domains) populate them upstream via identity-hook plugins; this +// crate just reads what the bag carries. +// +// # CPEX-provided context +// +// In addition to whatever the policy author put in `args.context`, the +// resolver merges in well-known CPEX context paths so policies can +// reason about them with a stable schema: +// +// - `context.delegation` — `{ chain: [...], depth: N }` from +// `DelegationExtension` (via bag's `delegation.*`). +// - `context.meta` — `{ entity_type, entity_name, scope, tags }` +// from `MetaExtension`. +// - `context.security` — `{ labels: [...], classification }`. +// +// Operators document this layout in their Cedar schema; policy authors +// rely on it. See `docs/specs/cedar-context-contract.md` for the +// authoritative shape. +// +// # Schema (optional) +// +// Cedar schemas validate policies at load time and requests at +// evaluation time. Recommended for production deployments; skipped here +// by default to keep the construction surface simple. Add via +// `CedarDirectResolver::with_schema(schema)`. +// +// # Decision attribution +// +// Cedar's `Response::diagnostics().reason()` returns the policy IDs of +// every policy that determined the decision. These flow back through +// `PdpDecision.diagnostics`, and the first one becomes the +// `rule_source` on Deny — so APL violations carry "denied via +// owner-override" instead of an opaque "cedar.deny." +// +// Policy authors should annotate every policy with `@id("...")`: +// +// ``` +// @id("owner-override") +// permit(principal, action == Action::"read", resource) +// when { principal == resource.owner }; +// ``` +// +// Without `@id` annotations, Cedar generates `policy0`, `policy1`, … +// which is stable but meaningless. Worth documenting as best practice. + +pub mod cedar_attrs; +pub mod decision; +pub mod entities; +pub mod error; +pub mod factory; +pub mod request; +pub mod resolver; +pub mod template; + +pub use error::BuildError; +pub use factory::CedarDirectPdpFactory; +pub use resolver::CedarDirectResolver; diff --git a/crates/apl-pdp-cedar-direct/src/request.rs b/crates/apl-pdp-cedar-direct/src/request.rs new file mode 100644 index 00000000..4c952aed --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/request.rs @@ -0,0 +1,216 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/request.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build a `cedar_policy::Request` from a `PdpCall` + `AttributeBag`. +// The resolver constructs Cedar's three required parts (principal, +// action, resource) plus the merged context, then hands them to +// Cedar's `Request::builder()`. +// +// # Principal / resource / action +// +// - **Principal:** built from the bag (see `entities::build_principal`). +// Its `EntityUid` is what we hand to `Request::principal()`. +// - **Resource:** built from `args.resource` (see `entities::build_resource`). +// - **Action:** parsed from `args.action` — must be a fully-qualified +// Cedar `EntityUid` literal like `Action::"read"` or +// `Acme::Action::"approve"`. The policy author writes this verbatim +// in their APL `cedar:(...)` step. +// +// # Context +// +// `args.context` is the operator-supplied context from the APL step. We +// merge in CPEX-provided keys at well-known paths: +// +// - `context.delegation.{chain, depth}` ← from bag's `delegation.*` +// - `context.meta.{entity_type, entity_name, scope, tags}` ← from bag's `meta.*` +// - `context.security.{labels, classification}` ← from bag's `security.*` +// +// Operators write Cedar policies against these stable paths. Any keys +// the operator put in `args.context` win over CPEX-provided defaults on +// conflict — operator intent first. +// +// # Schema +// +// When a schema is supplied, Cedar's `Context::from_json_value` validates +// the context's record shape against the action's declared context type. +// Without a schema, Cedar accepts any record. + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use apl_core::step::{PdpCall, PdpError}; +use cedar_policy::{EntityUid, Schema}; +use serde_json::{json, Map, Value}; + +/// Parsed pieces of a `PdpCall` ready to feed into +/// `cedar_policy::Request::builder()`. We pull this into its own +/// struct so the resolver can sequence "build entities → build request" +/// without a giant function signature. +pub struct ParsedCall<'a> { + pub action: EntityUid, + pub context: cedar_policy::Context, + pub resource_args: &'a serde_yaml::Value, +} + +/// Parse the args + bag into the pieces a Cedar request builder needs. +/// Schema is optional; when present, the context block is validated +/// against the action's declared context shape. +pub fn parse<'a>( + call: &'a PdpCall, + bag: &AttributeBag, + schema: Option<&Schema>, +) -> Result, PdpError> { + let map = call.args.as_mapping().ok_or_else(|| { + PdpError::Dispatch( + "cedar:() args must be a mapping with `action` and `resource` keys".to_string(), + ) + })?; + + let action_str = map + .get(serde_yaml::Value::String("action".to_string())) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + PdpError::Dispatch( + "cedar:() `action` missing — provide a fully-qualified UID \ + like 'Action::\"read\"'" + .to_string(), + ) + })?; + let action: EntityUid = action_str.parse().map_err(|e| { + PdpError::Dispatch(format!( + "cedar:() `action` '{}' not a valid EntityUid: {}", + action_str, e + )) + })?; + + let resource_args = map + .get(serde_yaml::Value::String("resource".to_string())) + .ok_or_else(|| { + PdpError::Dispatch("cedar:() `resource` missing".to_string()) + })?; + + // Build the merged context: operator-supplied `args.context` keys, + // overlaid on top of CPEX-derived context (delegation, meta, + // security). On collision, the operator's value wins — they + // explicitly wrote it. + let cpex_ctx = build_cpex_context(bag); + let operator_ctx = map + .get(serde_yaml::Value::String("context".to_string())) + .cloned() + .unwrap_or(serde_yaml::Value::Null); + let mut merged = cpex_ctx; + if !operator_ctx.is_null() { + let op_json: Value = serde_json::to_value(&operator_ctx).map_err(|e| { + PdpError::Dispatch(format!( + "cedar:() `context` not JSON-representable: {}", + e + )) + })?; + merge_into(&mut merged, op_json); + } + + let cedar_context = cedar_policy::Context::from_json_value(merged, None).map_err(|e| { + PdpError::Dispatch(format!("failed to construct Cedar context: {}", e)) + })?; + // Note: schema-validated context construction takes an + // (action_schema, action) pair via Cedar's `from_json_value`. For + // v0 we skip schema-side validation of the context shape — the + // request builder still applies whole-request validation when a + // schema is wired into the resolver. Adding context-level schema + // validation is a polish item; doesn't change decision semantics + // when the policies are well-formed. + let _ = schema; // schema currently used at request-build time, not here + + Ok(ParsedCall { + action, + context: cedar_context, + resource_args, + }) +} + +/// Build the CPEX-provided context block (everything under +/// `context.delegation`, `context.meta`, `context.security`) from the +/// `AttributeBag`. Operators reason about these in Cedar policies via +/// the well-known paths documented in `docs/specs/cedar-context-contract.md`. +fn build_cpex_context(bag: &AttributeBag) -> Value { + let mut root = Map::new(); + + let mut delegation = Map::new(); + if let Some(depth) = bag.get_int("delegation.depth") { + delegation.insert("depth".to_string(), json!(depth)); + } + // The full chain isn't currently in a flat bag key; apl-cmf + // exposes presence-only `delegated=true` plus per-attribute hops. + // When apl-cmf grows a structured `delegation.chain` shape we'll + // forward it here. For now, the depth + delegated bool let policies + // do basic chain-depth bounds checks. + if let Some(delegated) = bag.get_bool("delegated") { + delegation.insert("delegated".to_string(), json!(delegated)); + } + if !delegation.is_empty() { + root.insert("delegation".to_string(), Value::Object(delegation)); + } + + let mut meta = Map::new(); + if let Some(et) = bag.get_string("meta.entity_type") { + meta.insert("entity_type".to_string(), json!(et)); + } + if let Some(en) = bag.get_string("meta.entity_name") { + meta.insert("entity_name".to_string(), json!(en)); + } + if let Some(scope) = bag.get_string("meta.scope") { + meta.insert("scope".to_string(), json!(scope)); + } + if let Some(tags) = bag.get_string_set("meta.tags") { + meta.insert("tags".to_string(), json!(tags.iter().collect::>())); + } + if !meta.is_empty() { + root.insert("meta".to_string(), Value::Object(meta)); + } + + let mut security = Map::new(); + if let Some(labels) = bag.get_string_set("security.labels") { + security.insert("labels".to_string(), json!(labels.iter().collect::>())); + } + if let Some(cls) = bag.get_string("security.classification") { + security.insert("classification".to_string(), json!(cls)); + } + if !security.is_empty() { + root.insert("security".to_string(), Value::Object(security)); + } + + // Pass `authenticated` through as a top-level convenience for + // policies that want `context.authenticated` shorthand. + if let Some(auth) = bag.get_bool("authenticated") { + root.insert("authenticated".to_string(), json!(auth)); + } + + Value::Object(root) +} + +/// Shallow merge `overlay` into `target`. Operator-supplied keys win on +/// conflict at the top level; we don't try to deep-merge nested +/// records (operator says `context.meta = {custom: "x"}` and CPEX- +/// provided context.meta is fully replaced). Keeps the semantics +/// predictable. +fn merge_into(target: &mut Value, overlay: Value) { + let (Value::Object(target_map), Value::Object(overlay_map)) = (target, overlay) else { + return; + }; + for (k, v) in overlay_map { + target_map.insert(k, v); + } +} + +#[allow(dead_code)] +fn _bag_typed_value(v: &AttributeValue) -> Value { + // Reserved for future use — keeps the import alive while parts of + // the bag→JSON translation are stubbed. + match v { + AttributeValue::Bool(b) => json!(*b), + AttributeValue::Int(i) => json!(*i), + AttributeValue::Float(f) => json!(*f), + AttributeValue::String(s) => json!(s), + AttributeValue::StringSet(set) => json!(set.iter().collect::>()), + } +} diff --git a/crates/apl-pdp-cedar-direct/src/resolver.rs b/crates/apl-pdp-cedar-direct/src/resolver.rs new file mode 100644 index 00000000..348ed2ff --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/resolver.rs @@ -0,0 +1,301 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/resolver.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CedarDirectResolver` — the `PdpResolver` implementation. Wraps a +// loaded `PolicySet`, an `Authorizer`, and an optional `Schema`, and +// translates each APL `PdpCall` into a Cedar request → decision. +// +// # Construction surface +// +// Three constructors covering the typical sources of Cedar policy: +// +// - `from_policy_text(text)` — for inline policy in code or +// unified-config YAML. +// - `from_policy_file(path)` — for ops-managed policy files. +// - `from_config(value)` — for the unified-config block the +// `AplConfigVisitor` parses. Accepts +// either `policy_text` or +// `policy_file` (or both — policy_text +// wins). Also accepts `schema_text` / +// `schema_file` for optional schema +// loading, plus `entity_namespace` +// and `dialect`. +// +// Construction errors carry rich Cedar-specific messages via +// [`BuildError`]; the visitor wraps these into `VisitorError` → +// `PluginError::Config` at the manager boundary. + +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use cedar_policy::{Authorizer, PolicySet, Schema}; + +use apl_core::attributes::AttributeBag; +use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; + +use crate::decision::translate; +use crate::entities::build as build_entities; +use crate::error::BuildError; +use crate::request::parse as parse_call; + +/// PdpResolver wrapping a bare `cedar-policy` engine. Constructed from +/// policy text / file / config block at startup; evaluates each call +/// against the loaded `PolicySet`. +pub struct CedarDirectResolver { + policies: Arc, + schema: Option>, + authorizer: Authorizer, + dialect: PdpDialect, + /// Optional namespace applied to subject types: `Some("Acme")` + /// turns "User" into "Acme::User" when building the principal + /// entity. Lets schemas that namespace their entity types work + /// without policy authors having to hand-prefix every reference. + entity_namespace: Option, +} + +impl CedarDirectResolver { + /// Build a resolver from inline Cedar policy text. Use this for + /// tests, demos, and configs where the policy is small enough to + /// embed in YAML. + pub fn from_policy_text(policies: &str) -> Result { + let policy_set: PolicySet = policies + .parse() + .map_err(|e: cedar_policy::ParseErrors| BuildError::PolicyParse(e.to_string()))?; + Ok(Self { + policies: Arc::new(policy_set), + schema: None, + authorizer: Authorizer::new(), + dialect: PdpDialect::Cedar, + entity_namespace: None, + }) + } + + /// Build a resolver from a Cedar policy file on disk. Convenience + /// over `from_policy_text` for the production layout where policies + /// live in their own versioned files. + pub fn from_policy_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = std::fs::read_to_string(path).map_err(|source| BuildError::PolicyFile { + path: path.display().to_string(), + source, + })?; + Self::from_policy_text(&text) + } + + /// Build a resolver from a unified-config block. Shape: + /// + /// ```yaml + /// dialect: cedar # optional; default PdpDialect::Cedar + /// entity_namespace: Acme # optional; prefixes subject types + /// policy_text: | # required (or policy_file) + /// @id("owner-override") + /// permit(...); + /// policy_file: /etc/... # alternative to policy_text + /// schema_text: | # optional + /// ... + /// schema_file: /etc/... # alternative to schema_text + /// ``` + /// + /// `policy_text` wins over `policy_file` when both are present. + /// Same for `schema_text` over `schema_file`. Called by + /// `AplConfigVisitor` when it sees a Cedar PDP block in the + /// unified-config YAML. + pub fn from_config(value: &serde_yaml::Value) -> Result { + let map = value + .as_mapping() + .ok_or_else(|| BuildError::ConfigShape("Cedar PDP config must be a mapping".into()))?; + + // ----- policy source ----- + let policy_text = read_yaml_string(map, "policy_text"); + let policy_file = read_yaml_string(map, "policy_file"); + let policies = match (policy_text, policy_file) { + (Some(text), _) => text, + (None, Some(path)) => { + std::fs::read_to_string(&path).map_err(|source| BuildError::PolicyFile { + path: path.clone(), + source, + })? + } + (None, None) => { + return Err(BuildError::ConfigShape( + "Cedar PDP config requires `policy_text` or `policy_file`".into(), + )); + } + }; + let policy_set: PolicySet = policies + .parse() + .map_err(|e: cedar_policy::ParseErrors| BuildError::PolicyParse(e.to_string()))?; + + // ----- optional schema ----- + let schema_text = read_yaml_string(map, "schema_text"); + let schema_file = read_yaml_string(map, "schema_file"); + let schema = match (schema_text, schema_file) { + (Some(text), _) => Some(parse_schema(&text)?), + (None, Some(path)) => { + let text = std::fs::read_to_string(&path).map_err(|source| BuildError::SchemaFile { + path: path.clone(), + source, + })?; + Some(parse_schema(&text)?) + } + (None, None) => None, + }; + + // ----- optional dialect override ----- + let dialect = match read_yaml_string(map, "dialect").as_deref() { + None | Some("cedar") => PdpDialect::Cedar, + Some(other) => PdpDialect::Custom(other.to_string()), + }; + + let entity_namespace = read_yaml_string(map, "entity_namespace"); + + Ok(Self { + policies: Arc::new(policy_set), + schema: schema.map(Arc::new), + authorizer: Authorizer::new(), + dialect, + entity_namespace, + }) + } + + /// Override the resolver's dialect. Lets operators register a Cedar + /// engine under a custom name (e.g. `PdpDialect::Custom("workload")`) + /// so they can coexist with another Cedar engine on the same + /// `PdpRouter`. + pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { + self.dialect = dialect; + self + } + + /// Attach an `entity_namespace`. Applied at request time to + /// subject types: `Some("Acme")` + bag `subject.type=User` → + /// principal UID `Acme::User::""`. + pub fn with_entity_namespace(mut self, namespace: impl Into) -> Self { + self.entity_namespace = Some(namespace.into()); + self + } + + /// Attach a schema after construction. Useful when the schema + /// comes from a separate source than the policy text. + pub fn with_schema(mut self, schema: Schema) -> Self { + self.schema = Some(Arc::new(schema)); + self + } +} + +#[async_trait] +impl PdpResolver for CedarDirectResolver { + fn dialect(&self) -> PdpDialect { + self.dialect.clone() + } + + async fn evaluate( + &self, + call: &PdpCall, + bag: &AttributeBag, + ) -> Result { + // Resolve `${bag-key}` placeholders in the call's args against + // the bag before any parsing. The author writes things like + // `id: ${args.repo_name}`; this pass turns them into concrete + // values so downstream entity / UID builders can stay literal. + let resolved_args = crate::template::resolve_refs(&call.args, bag)?; + let resolved_call = PdpCall { + dialect: call.dialect.clone(), + args: resolved_args, + }; + + let parsed = parse_call(&resolved_call, bag, self.schema.as_deref())?; + let entities = build_entities( + bag, + parsed.resource_args, + self.schema.as_deref(), + self.entity_namespace.as_deref(), + )?; + + let principal_uid = build_principal_uid(bag, self.entity_namespace.as_deref())?; + let resource_uid = build_resource_uid(parsed.resource_args)?; + + let request = cedar_policy::Request::new( + principal_uid, + parsed.action, + resource_uid, + parsed.context, + self.schema.as_deref(), + ) + .map_err(|e| PdpError::Dispatch(format!("Cedar request validation failed: {}", e)))?; + + let response = self + .authorizer + .is_authorized(&request, &self.policies, &entities); + + Ok(translate(&response, &self.policies)) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn parse_schema(text: &str) -> Result { + Schema::from_cedarschema_str(text) + .map(|(schema, _warnings)| schema) + .map_err(|e| BuildError::SchemaParse(e.to_string())) +} + +fn read_yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_string()))? + .as_str() + .map(|s| s.to_string()) +} + +/// Build the principal `EntityUid` for the Cedar request. Returns the +/// SAME UID that `entities::build_principal` produces; both have to +/// agree on type + id since Cedar resolves the request's principal +/// reference into the entity set by UID equality. +fn build_principal_uid( + bag: &AttributeBag, + namespace: Option<&str>, +) -> Result { + let id = bag + .get_string("subject.id") + .ok_or_else(|| PdpError::Dispatch("bag missing `subject.id`".to_string()))?; + let kind = bag.get_string("subject.type").unwrap_or("User"); + let entity_type = match namespace { + Some(ns) if !ns.is_empty() => format!("{}::{}", ns, kind), + _ => kind.to_string(), + }; + let uid_str = format!("{}::\"{}\"", entity_type, escape_id(id)); + uid_str.parse().map_err(|e| { + PdpError::Dispatch(format!( + "failed to parse principal UID '{}': {}", + uid_str, e + )) + }) +} + +fn build_resource_uid(resource_args: &serde_yaml::Value) -> Result { + let map = resource_args.as_mapping().ok_or_else(|| { + PdpError::Dispatch("cedar:() `resource` must be a mapping".to_string()) + })?; + let type_name = read_yaml_string(map, "type") + .ok_or_else(|| PdpError::Dispatch("cedar:() `resource.type` missing".to_string()))?; + let id = read_yaml_string(map, "id") + .ok_or_else(|| PdpError::Dispatch("cedar:() `resource.id` missing".to_string()))?; + let uid_str = format!("{}::\"{}\"", type_name, escape_id(&id)); + uid_str.parse().map_err(|e| { + PdpError::Dispatch(format!( + "failed to parse resource UID '{}': {}", + uid_str, e + )) + }) +} + +/// Cedar identifiers in double-quoted form need backslash + quote +/// escaping. Most subject IDs are well-behaved (UUIDs, JWT sub +/// claims) — escape defensively for the cases that aren't. +fn escape_id(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/crates/apl-pdp-cedar-direct/src/template.rs b/crates/apl-pdp-cedar-direct/src/template.rs new file mode 100644 index 00000000..1479661e --- /dev/null +++ b/crates/apl-pdp-cedar-direct/src/template.rs @@ -0,0 +1,281 @@ +// Location: ./crates/apl-pdp-cedar-direct/src/template.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `${bag-key}` substitution for `cedar:(...)` step args. +// +// APL authors write Cedar requests like: +// +// - cedar: +// action: 'Action::"read"' +// resource: +// type: Repo +// id: ${args.repo_name} +// attributes: +// visibility: ${args.visibility} +// owner_id: ${subject.id} +// +// This module walks the YAML and rewrites any **scalar string** equal to +// `${}` by reading the value from the `AttributeBag`. Strings +// without the `${...}` wrapper pass through unchanged, so policy authors +// can still write literals like `'Action::"read"'` or `User::"alice"` +// without surprise rewrites. +// +// # Why this looks like template substitution and not magic prefixes +// +// An earlier sketch let bare `args.X` strings substitute implicitly. +// That was load-bearing on a single hardcoded namespace and conflated +// "the author meant a placeholder" with "the author meant a string that +// happens to start with `args.`". The `${...}` form is explicit and +// generalizes to any bag key: +// +// ${subject.id} ${subject.type} +// ${role.engineer} ${perm.view_ssn} +// ${claim.email} ${args.repo_name} ${args.user.id} +// ${delegation.granted.audience} ${meta.entity_name} +// +// The vocabulary mirrors the `MessageView` projection (the bag is +// populated by apl-cmf's `extract_security` / `extract_args` from the +// same source data the view sees), so a Cedar resource template and an +// OPA `input.X` rego path can name the same attribute the same way. +// When (in a separate refactor) `AttributeBag` becomes a derived +// projection of `MessageView`, this substitution layer doesn't change — +// it's already reading the normalized vocabulary. +// +// # What gets substituted +// +// - Whole-string match: `${args.repo_name}` → value at `args.repo_name`. +// - Embedded placeholders (`prefix-${args.X}-suffix`) are NOT supported +// in v0; whole-string only. Easy to extend later, but YAGNI today — +// Cedar entity IDs / attrs almost always want the raw value. +// - Missing bag key → loud `PdpError::Dispatch`. Falling back to the +// literal would mask author bugs. +// - Mappings + sequences recurse into their members. + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use apl_core::step::PdpError; + +/// Recursively walk `value`, substituting any `${}` scalar with +/// the corresponding bag value. Mappings and sequences recurse. Other +/// scalars pass through unchanged. +pub fn resolve_refs( + value: &serde_yaml::Value, + bag: &AttributeBag, +) -> Result { + match value { + serde_yaml::Value::String(s) => { + if let Some(key) = parse_placeholder(s) { + substitute(key, s, bag) + } else { + Ok(value.clone()) + } + } + serde_yaml::Value::Mapping(map) => { + let mut out = serde_yaml::Mapping::new(); + for (k, v) in map { + out.insert(k.clone(), resolve_refs(v, bag)?); + } + Ok(serde_yaml::Value::Mapping(out)) + } + serde_yaml::Value::Sequence(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(resolve_refs(item, bag)?); + } + Ok(serde_yaml::Value::Sequence(out)) + } + _ => Ok(value.clone()), + } +} + +/// Return the inner bag key when `s` is exactly `${}` (whole-string +/// placeholder). Returns `None` for any other shape — including +/// `prefix-${args.X}` (embedded), `$args.X` (no braces), or stray `${` +/// without a matching `}`. +fn parse_placeholder(s: &str) -> Option<&str> { + let inner = s.strip_prefix("${")?.strip_suffix('}')?; + let trimmed = inner.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn substitute( + key: &str, + original: &str, + bag: &AttributeBag, +) -> Result { + let value = bag.get(key).ok_or_else(|| { + PdpError::Dispatch(format!( + "cedar:() references `{}` but the bag has no key `{}` — \ + check the spelling against the projection vocabulary \ + populated by apl-cmf (security / payload extractors)", + original, key + )) + })?; + + Ok(match value { + AttributeValue::String(v) => serde_yaml::Value::String(v.clone()), + AttributeValue::Bool(v) => serde_yaml::Value::Bool(*v), + AttributeValue::Int(v) => serde_yaml::Value::Number((*v).into()), + AttributeValue::Float(v) => serde_yaml::Value::Number(serde_yaml::Number::from(*v)), + AttributeValue::StringSet(set) => { + let items: Vec = set + .iter() + .map(|s| serde_yaml::Value::String(s.clone())) + .collect(); + serde_yaml::Value::Sequence(items) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bag_with(kvs: &[(&str, &str)]) -> AttributeBag { + let mut bag = AttributeBag::new(); + for (k, v) in kvs { + bag.set(*k, *v); + } + bag + } + + #[test] + fn substitutes_args_inside_mapping() { + let bag = bag_with(&[ + ("args.repo_name", "web-app"), + ("args.visibility", "internal"), + ]); + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +type: Repo +id: ${args.repo_name} +attributes: + visibility: ${args.visibility} +"#, + ) + .unwrap(); + + let resolved = resolve_refs(&yaml, &bag).unwrap(); + let map = resolved.as_mapping().unwrap(); + assert_eq!( + map.get(serde_yaml::Value::String("id".into())) + .and_then(|v| v.as_str()), + Some("web-app") + ); + let attrs = map + .get(serde_yaml::Value::String("attributes".into())) + .unwrap() + .as_mapping() + .unwrap(); + assert_eq!( + attrs + .get(serde_yaml::Value::String("visibility".into())) + .and_then(|v| v.as_str()), + Some("internal") + ); + } + + #[test] + fn substitutes_across_namespaces() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + bag.set("args.repo_name", "core"); + bag.set("claim.email", "alice@corp.com"); + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +owner: ${subject.id} +target: ${args.repo_name} +email: ${claim.email} +"#, + ) + .unwrap(); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + let map = resolved.as_mapping().unwrap(); + assert_eq!( + map.get(serde_yaml::Value::String("owner".into())) + .and_then(|v| v.as_str()), + Some("alice") + ); + assert_eq!( + map.get(serde_yaml::Value::String("target".into())) + .and_then(|v| v.as_str()), + Some("core") + ); + assert_eq!( + map.get(serde_yaml::Value::String("email".into())) + .and_then(|v| v.as_str()), + Some("alice@corp.com") + ); + } + + #[test] + fn passes_through_literal_strings() { + let bag = bag_with(&[("args.x", "ignored")]); + // No `${...}` wrapper → literal. + let yaml = serde_yaml::Value::String("User::\"alice\"".into()); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + assert_eq!(resolved.as_str(), Some("User::\"alice\"")); + // Even bare `args.x` is now a literal — the explicit `${...}` + // form is the only thing that triggers substitution. + let yaml = serde_yaml::Value::String("args.x".into()); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + assert_eq!(resolved.as_str(), Some("args.x")); + } + + #[test] + fn missing_bag_key_errors_loudly() { + let bag = AttributeBag::new(); + let yaml = serde_yaml::Value::String("${args.missing}".into()); + let err = resolve_refs(&yaml, &bag).unwrap_err(); + let msg = format!("{:?}", err); + assert!(msg.contains("args.missing"), "error mentions the key: {}", msg); + } + + #[test] + fn substitutes_typed_values() { + let mut bag = AttributeBag::new(); + bag.set("args.flag", true); + bag.set("args.count", 42i64); + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +flag: ${args.flag} +count: ${args.count} +"#, + ) + .unwrap(); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + let map = resolved.as_mapping().unwrap(); + assert_eq!( + map.get(serde_yaml::Value::String("flag".into())) + .and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + map.get(serde_yaml::Value::String("count".into())) + .and_then(|v| v.as_i64()), + Some(42) + ); + } + + #[test] + fn embedded_placeholders_not_supported_in_v0() { + let bag = bag_with(&[("args.x", "hello")]); + let yaml = serde_yaml::Value::String("prefix-${args.x}-suffix".into()); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + // Whole-string only — embedded `${...}` is left alone. + assert_eq!(resolved.as_str(), Some("prefix-${args.x}-suffix")); + } + + #[test] + fn empty_placeholder_is_literal() { + let bag = AttributeBag::new(); + let yaml = serde_yaml::Value::String("${}".into()); + let resolved = resolve_refs(&yaml, &bag).unwrap(); + assert_eq!(resolved.as_str(), Some("${}")); + } +} diff --git a/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs b/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs new file mode 100644 index 00000000..05400a4b --- /dev/null +++ b/crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs @@ -0,0 +1,220 @@ +// Location: ./crates/apl-pdp-cedar-direct/tests/basic_allow_deny.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Smoke tests for `CedarDirectResolver`. Cover the canonical +// allow/deny paths, the role-driven case (proves bag attributes reach +// the principal entity), and the policy-id attribution that operators +// rely on for audit logs. + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDialect, PdpResolver}; + +use apl_pdp_cedar_direct::CedarDirectResolver; + +/// Build a `PdpCall` against `Action::"read"` on a `Document::"doc-1"`. +/// Used across the test cases so the request side stays constant and +/// only the policy + bag varies. +fn read_doc_call() -> PdpCall { + PdpCall { + dialect: PdpDialect::Cedar, + args: serde_yaml::from_str( + r#" +action: 'Action::"read"' +resource: + type: Document + id: doc-1 +"#, + ) + .unwrap(), + } +} + +fn alice_bag() -> AttributeBag { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + bag.set("subject.type", "User"); + bag +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// One unconditional `permit` policy → request → Allow. Confirms the +/// happy path end-to-end: parse, build entities, build request, +/// authorize, translate decision back. +#[tokio::test] +async fn unconditional_permit_returns_allow() { + const POLICY: &str = r#" + @id("allow-all") + permit(principal, action, resource); + "#; + + let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_bag()) + .await + .expect("evaluate"); + + assert_eq!(decision.decision, Decision::Allow); + assert_eq!(decision.diagnostics, vec!["allow-all".to_string()]); +} + +/// No policies → default-deny. Confirms the fail-closed default that +/// drops out of Cedar's semantics (no `permit` matches, so the request +/// denies). +#[tokio::test] +async fn empty_policy_set_denies_by_default() { + let resolver = CedarDirectResolver::from_policy_text("").expect("empty policy set is valid"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_bag()) + .await + .expect("evaluate"); + + match decision.decision { + Decision::Deny { rule_source, .. } => { + assert_eq!(rule_source, "cedar.default_deny"); + } + other => panic!("expected Deny on empty policy set, got {:?}", other), + } + assert!(decision.diagnostics.is_empty(), "no policies fired"); +} + +/// A policy that requires `principal.roles.contains("hr")`. Bag has +/// `role.hr=true` → reaches principal.roles → Allow. Proves the +/// bag-attribute-to-entity-attribute translation works end-to-end: +/// apl-cmf would normally populate `role.hr` from +/// `SecurityExtension.subject.roles`, but the bag works the same way +/// however it got there. +#[tokio::test] +async fn role_in_bag_reaches_principal_attributes() { + const POLICY: &str = r#" + @id("hr-only") + permit(principal, action == Action::"read", resource) + when { principal.roles.contains("hr") }; + "#; + + let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); + + // Alice has role.hr → policy permits. + let mut bag = alice_bag(); + bag.set("role.hr", true); + let decision = resolver.evaluate(&read_doc_call(), &bag).await.expect("evaluate"); + assert_eq!(decision.decision, Decision::Allow); + assert_eq!(decision.diagnostics, vec!["hr-only".to_string()]); + + // Bob has no roles → policy doesn't match → default-deny. + let mut bob_bag = AttributeBag::new(); + bob_bag.set("subject.id", "bob"); + bob_bag.set("subject.type", "User"); + let decision = resolver + .evaluate(&read_doc_call(), &bob_bag) + .await + .expect("evaluate"); + match decision.decision { + Decision::Deny { rule_source, .. } => { + assert_eq!( + rule_source, "cedar.default_deny", + "no permit matched → default-deny, not policy-attributed" + ); + } + other => panic!("expected Deny for bob, got {:?}", other), + } +} + +/// A policy with `@id("blocklist")` that forbids access for a specific +/// principal. When the forbid fires, the violation's `rule_source` +/// should carry the policy id so wire errors / audit logs say +/// "denied via blocklist" instead of "denied by Cedar." +#[tokio::test] +async fn forbid_attribution_carries_policy_id() { + const POLICY: &str = r#" + @id("permit-all") + permit(principal, action, resource); + + @id("blocklist") + forbid(principal == User::"alice", action, resource); + "#; + + let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_bag()) + .await + .expect("evaluate"); + + match decision.decision { + Decision::Deny { rule_source, reason } => { + assert_eq!( + rule_source, "blocklist", + "violation should be attributed to the forbid policy by id" + ); + assert!( + reason.as_deref().unwrap_or("").contains("blocklist"), + "reason should mention the firing policy: {:?}", + reason + ); + } + other => panic!("expected Deny via blocklist, got {:?}", other), + } + assert!(decision.diagnostics.iter().any(|d| d == "blocklist")); +} + +/// Missing `subject.id` in the bag is a configuration fault (identity +/// hook didn't populate it). Resolver returns a Dispatch error rather +/// than silently building a malformed Cedar request. +#[tokio::test] +async fn missing_subject_id_errors_clearly() { + const POLICY: &str = "permit(principal, action, resource);"; + let resolver = CedarDirectResolver::from_policy_text(POLICY).expect("policy parses"); + + // Empty bag → no subject.id. + let bag = AttributeBag::new(); + let err = resolver + .evaluate(&read_doc_call(), &bag) + .await + .expect_err("should fail with no subject.id"); + + let msg = format!("{}", err); + assert!( + msg.contains("subject.id"), + "error should mention the missing key: {}", + msg + ); +} + +/// Construction from a config block — the path the visitor uses when +/// it sees a Cedar PDP block in unified-config YAML. +#[tokio::test] +async fn from_config_builds_resolver_from_yaml_block() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +dialect: cedar +policy_text: | + @id("from-config") + permit(principal, action, resource); +"#, + ) + .expect("yaml parses"); + + let resolver = CedarDirectResolver::from_config(&yaml).expect("config valid"); + let decision = resolver + .evaluate(&read_doc_call(), &alice_bag()) + .await + .expect("evaluate"); + assert_eq!(decision.decision, Decision::Allow); + assert_eq!(decision.diagnostics, vec!["from-config".to_string()]); +} + +/// Operators can register the resolver under a custom dialect to +/// coexist with another Cedar engine on the same PdpRouter. +#[tokio::test] +async fn with_dialect_overrides_default() { + let resolver = CedarDirectResolver::from_policy_text("permit(principal, action, resource);") + .expect("policy parses") + .with_dialect(PdpDialect::Custom("workload".to_string())); + + assert_eq!(resolver.dialect(), PdpDialect::Custom("workload".to_string())); +} diff --git a/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs b/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs new file mode 100644 index 00000000..76ec2c11 --- /dev/null +++ b/crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs @@ -0,0 +1,166 @@ +// Location: ./crates/apl-pdp-cedar-direct/tests/visitor_pdp_config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end integration: a unified-config YAML that +// +// 1. declares a `cedar-direct` PDP under `global.apl.pdp[]`, +// 2. embeds Cedar policy text inline in that declaration, +// 3. attaches a `cedar:(...)` policy step to a route, +// +// must flow a real authorization decision from the cpex-core dispatcher +// through `AplConfigVisitor` → `PdpFactory` → `CedarDirectResolver` → +// Cedar's `Authorizer` → back into the route handler's deny/allow split. +// +// This proves the *wiring* end-to-end. The cedar-direct unit tests in +// `basic_allow_deny.rs` already cover the resolver in isolation; what's +// special here is that the resolver was never instantiated in Rust by +// the test — the visitor built it from YAML at `load_config_yaml` time +// because the host registered `CedarDirectPdpFactory` via +// `AplOptions.pdp_factories`. If this test passes, an operator who +// drops a `cedar-direct` block into their config gets the same behavior +// without writing any glue. + +use std::collections::HashSet; +use std::sync::Arc; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::extensions::{ + MetaExtension, SecurityExtension, SubjectExtension, SubjectType, +}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; +use apl_pdp_cedar_direct::CedarDirectPdpFactory; + +// The configuration the visitor walks. Single Cedar permit policy that +// only fires for principals carrying the `reader` role; everything else +// hits Cedar's default-deny path. +const YAML: &str = r#" +global: + apl: + pdp: + - kind: cedar-direct + policy_text: | + @id("reader-permit") + permit(principal, action == Action::"read", resource) + when { principal.roles.contains("reader") }; +routes: + - tool: get_document + apl: + policy: + - cedar: + action: 'Action::"read"' + resource: + type: Document + id: doc-42 +"#; + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut m = MetaExtension::default(); + m.entity_type = Some("tool".to_string()); + m.entity_name = Some(name.to_string()); + m +} + +/// Build a `SecurityExtension` with the given subject id and roles. The +/// bag-builder lifts these into `subject.id` / `role.` keys, which +/// `entities.rs` reads when constructing the Cedar principal. Anything +/// the policy needs about the principal must come through this surface. +fn security_with_roles(id: &str, roles: &[&str]) -> SecurityExtension { + SecurityExtension { + subject: Some(SubjectExtension { + id: Some(id.to_string()), + subject_type: Some(SubjectType::User), + roles: roles.iter().map(|r| r.to_string()).collect::>(), + ..Default::default() + }), + ..Default::default() + } +} + +async fn build_manager() -> Arc { + let mgr = Arc::new(PluginManager::default()); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + // The factory is the load-bearing wiring under test: the + // visitor sees `kind: cedar-direct` in YAML and finds this + // factory by key. + pdp_factories: vec![Arc::new(CedarDirectPdpFactory::new())], + base_capabilities: None, + }, + ); + mgr.load_config_yaml(YAML).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + mgr +} + +fn payload() -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, "fetch doc-42"), + } +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Principal `alice` carries `role.reader=true`, which the permit policy +/// requires. End-to-end: visitor built the resolver from YAML, route +/// handler dispatched the `cedar:` step into that resolver, Cedar +/// returned Allow, the pipeline continues. +#[tokio::test] +async fn config_declared_cedar_pdp_allows_reader() { + let mgr = build_manager().await; + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("alice", &["reader"]))), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) + .await; + + assert!( + result.continue_processing, + "reader-permit should allow alice; got violation = {:?}", + result.violation + ); +} + +/// Principal `bob` carries no roles, so the permit's guard +/// (`principal.roles.contains("reader")`) is false and no other policy +/// fires. Cedar default-denies; the route handler maps that to a +/// pipeline-halting violation with `code = cedar.default_deny`. +#[tokio::test] +async fn config_declared_cedar_pdp_denies_non_reader() { + let mgr = build_manager().await; + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("bob", &[]))), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) + .await; + + assert!( + !result.continue_processing, + "missing reader role should default-deny", + ); + let v = result.violation.expect("deny path must surface a violation"); + assert_eq!( + v.code, "cedar.default_deny", + "default-deny path should use the cedar-direct sentinel code; got {}", + v.code + ); +} diff --git a/crates/apl-pii-scanner/Cargo.toml b/crates/apl-pii-scanner/Cargo.toml new file mode 100644 index 00000000..89369aae --- /dev/null +++ b/crates/apl-pii-scanner/Cargo.toml @@ -0,0 +1,28 @@ +# Location: ./crates/apl-pii-scanner/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-pii-scanner — CMF plugin that detects PII patterns (SSN, +# credit card, email) in tool/prompt/resource args and either denies +# the call, taints the session, or redacts the matching values. + +[package] +name = "apl-pii-scanner" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +cpex-core = { path = "../cpex-core" } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +regex = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-pii-scanner/src/config.rs b/crates/apl-pii-scanner/src/config.rs new file mode 100644 index 00000000..e0ecdeb7 --- /dev/null +++ b/crates/apl-pii-scanner/src/config.rs @@ -0,0 +1,85 @@ +// Location: ./crates/apl-pii-scanner/src/config.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use serde::{Deserialize, Serialize}; + +/// Plugin config — what operators write under +/// `plugins[].config:` in unified-config YAML. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PiiScannerConfig { + /// Which patterns to detect. Defaults to `[ssn, credit_card]` + /// which covers the common high-signal cases. + #[serde(default = "default_detect")] + pub detect: Vec, + + /// What to do when a match is found. + #[serde(default)] + pub mode: PiiScanMode, +} + +fn default_detect() -> Vec { + vec![PiiPattern::Ssn, PiiPattern::CreditCard] +} + +/// Built-in PII pattern catalog. Patterns chosen for high signal-to- +/// noise on the kinds of values that flow through agent tool calls. +/// Operators can supply a custom regex via `PiiPattern::Custom`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PiiPattern { + /// US Social Security Number: `NNN-NN-NNNN`. + Ssn, + /// Credit-card-like sequences (13-19 digits, optional separators). + /// Note: does NOT Luhn-check — for v0 the regex match is enough + /// to flag. Luhn validation is a future refinement. + CreditCard, + /// Email address. Surprisingly common false-positive risk — + /// operators turn this off if their tools legitimately deal in + /// email addresses (HR directory, contact lists). + Email, + /// Operator-supplied regex. Useful for company-specific IDs + /// (employee IDs that aren't already public, internal account + /// numbers, etc.). + Custom { name: String, regex: String }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PiiScanMode { + /// Return `pii.detected` violation — gateway translates to 403. + /// The strictest mode; the request never reaches downstream. + #[default] + Deny, + /// Replace each matching value with `[PII]` in the outbound + /// payload. Lets the request through but with secrets neutered. + Redact, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn defaults() { + let cfg: PiiScannerConfig = serde_json::from_value(json!({})).unwrap(); + assert_eq!(cfg.detect.len(), 2); + assert!(matches!(cfg.mode, PiiScanMode::Deny)); + } + + #[test] + fn parse_full_config() { + let raw = json!({ + "detect": [ + { "kind": "ssn" }, + { "kind": "custom", "name": "internal_id", "regex": "^INT-[A-Z0-9]{10}$" } + ], + "mode": "redact", + }); + let cfg: PiiScannerConfig = serde_json::from_value(raw).unwrap(); + assert_eq!(cfg.detect.len(), 2); + assert!(matches!(cfg.mode, PiiScanMode::Redact)); + } +} diff --git a/crates/apl-pii-scanner/src/factory.rs b/crates/apl-pii-scanner/src/factory.rs new file mode 100644 index 00000000..66f46995 --- /dev/null +++ b/crates/apl-pii-scanner/src/factory.rs @@ -0,0 +1,70 @@ +// Location: ./crates/apl-pii-scanner/src/factory.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use std::sync::Arc; + +use cpex_core::{ + cmf::CmfHook, + error::PluginError, + factory::{PluginFactory, PluginInstance}, + hooks::TypedHandlerAdapter, + plugin::PluginConfig, +}; + +use crate::scanner::PiiScanner; + +/// `kind:` string operators write in CPEX YAML to declare a PII +/// scanner instance. +pub const KIND: &str = "validator/pii-scan"; + +/// Factory for `kind: validator/pii-scan`. Instantiates a +/// `PiiScanner` from the `config:` block and registers a handler +/// for every CMF hook name listed in `cfg.hooks`. Operators +/// typically wire it on `cmf.tool_pre_invoke` / +/// `cmf.prompt_pre_invoke` / `cmf.resource_pre_fetch` so it runs +/// before any of those entity types reach the backend. +pub struct PiiScannerFactory; + +impl PluginFactory for PiiScannerFactory { + fn create(&self, config: &PluginConfig) -> Result> { + let scanner = Arc::new(PiiScanner::new(config.clone())?); + + // Register the same handler instance against every CMF hook + // name the operator declared in YAML — same plugin, multiple + // entry points. Empty hooks list is a config error. + if config.hooks.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-pii-scanner): `hooks:` must list at \ + least one CMF hook to scan on (e.g. cmf.tool_pre_invoke)", + config.name + ), + })); + } + + let handlers: Vec<_> = config + .hooks + .iter() + .map(|h| -> (&'static str, _) { + // Leak the string to get a 'static lifetime — the + // handler registry stores it that way for cheap + // comparison. PluginConfigs are read once at startup + // and live for the process lifetime, so the leak + // bound is the number of plugin × hook pairs in + // config (small, bounded). + let leaked: &'static str = Box::leak(h.clone().into_boxed_str()); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&scanner)), + ); + (leaked, adapter) + }) + .collect(); + + Ok(PluginInstance { + plugin: scanner, + handlers, + }) + } +} diff --git a/crates/apl-pii-scanner/src/lib.rs b/crates/apl-pii-scanner/src/lib.rs new file mode 100644 index 00000000..6f5ee532 --- /dev/null +++ b/crates/apl-pii-scanner/src/lib.rs @@ -0,0 +1,30 @@ +// Location: ./crates/apl-pii-scanner/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-pii-scanner — CMF `HookHandler` that walks the message's +// ToolCall / PromptRequest argument map and tests each string value +// against configured PII patterns. Modes: +// +// * `deny` — return `pii.detected` violation; gateway 403s +// * `taint` — emit a session taint label (downstream policy can +// gate via `session.labels contains 'PII'`) +// * `redact` — replace matching values with `[PII]` and continue +// +// Operators wire it as a `policy:` step: +// +// policy: +// - "require(perm.email_send)" +// - "plugin(pii-scan)" +// +// The plugin registers on whichever CMF pre-invoke hooks the +// operator declares in YAML (tool / prompt / llm / resource). + +pub mod config; +pub mod factory; +pub mod scanner; + +pub use config::{PiiPattern, PiiScanMode, PiiScannerConfig}; +pub use factory::{PiiScannerFactory, KIND}; +pub use scanner::PiiScanner; diff --git a/crates/apl-pii-scanner/src/scanner.rs b/crates/apl-pii-scanner/src/scanner.rs new file mode 100644 index 00000000..3b18c839 --- /dev/null +++ b/crates/apl-pii-scanner/src/scanner.rs @@ -0,0 +1,322 @@ +// Location: ./crates/apl-pii-scanner/src/scanner.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor + +use std::sync::Arc; + +use async_trait::async_trait; +use regex::Regex; +use serde_json::Value; + +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::config::{PiiPattern, PiiScanMode, PiiScannerConfig}; + +/// CMF plugin that walks the message's ToolCall / PromptRequest / +/// ResourceRef arguments and tests each string value against the +/// configured PII patterns. +#[derive(Debug)] +pub struct PiiScanner { + cfg: PluginConfig, + typed: PiiScannerConfig, + /// Compiled regexes paired with the pattern name (for violation + /// attribution). Compiled once at construction; matched per call. + patterns: Vec<(String, Regex)>, +} + +impl PiiScanner { + pub fn new(cfg: PluginConfig) -> Result> { + let raw = cfg.config.as_ref().ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-pii-scanner) requires a `config:` block", + cfg.name + ), + }) + })?; + let typed: PiiScannerConfig = + serde_json::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-pii-scanner) config parse failed: {e}", + cfg.name + ), + }) + })?; + + let patterns = compile_patterns(&typed.detect, &cfg.name)?; + Ok(Self { cfg, typed, patterns }) + } + + /// Scan every string value in the message's structured content + /// (ToolCall.arguments, PromptRequest.arguments) plus any text + /// parts. Returns the name of the first matching pattern, or + /// `None` if no match. The pattern name flows into the violation + /// code so audit logs say `pii.detected: ssn` rather than + /// generic `pii.detected`. + fn first_match(&self, message: &Message) -> Option<&str> { + for part in &message.content { + match part { + ContentPart::ToolCall { content } => { + for v in content.arguments.values() { + if let Some(name) = self.match_value(v) { + return Some(name); + } + } + } + ContentPart::PromptRequest { content } => { + for v in content.arguments.values() { + if let Some(name) = self.match_value(v) { + return Some(name); + } + } + } + ContentPart::Text { text } => { + if let Some(name) = self.match_str(text) { + return Some(name); + } + } + _ => {} // images / video / audio / etc. — out of scope for v0 + } + } + None + } + + fn match_value(&self, v: &Value) -> Option<&str> { + match v { + Value::String(s) => self.match_str(s), + // Numbers / bools can't carry PII patterns. Arrays / + // objects could be walked recursively in a future + // version; for now we only flag flat string fields, + // which covers the common LLM tool-call shape. + _ => None, + } + } + + fn match_str(&self, s: &str) -> Option<&str> { + for (name, re) in &self.patterns { + if re.is_match(s) { + return Some(name); + } + } + None + } + + /// Rewrite the message's content: replace any string value that + /// matches a pattern with `[PII]`. Used in `redact` mode. + fn redact_message(&self, message: &mut Message) { + for part in message.content.iter_mut() { + match part { + ContentPart::ToolCall { content } => { + for v in content.arguments.values_mut() { + self.redact_value(v); + } + } + ContentPart::PromptRequest { content } => { + for v in content.arguments.values_mut() { + self.redact_value(v); + } + } + ContentPart::Text { text } => { + if self.match_str(text).is_some() { + *text = "[PII]".to_string(); + } + } + _ => {} + } + } + } + + fn redact_value(&self, v: &mut Value) { + if let Value::String(s) = v { + if self.match_str(s).is_some() { + *v = Value::String("[PII]".to_string()); + } + } + } +} + +fn compile_patterns( + patterns: &[PiiPattern], + plugin_name: &str, +) -> Result, Box> { + let mut out = Vec::with_capacity(patterns.len()); + for p in patterns { + let (name, re_str) = match p { + PiiPattern::Ssn => ("ssn", r"\b\d{3}-\d{2}-\d{4}\b".to_string()), + PiiPattern::CreditCard => ( + "credit_card", + // 13-19 digit sequences with optional spaces / hyphens + // every 4 digits. Liberal — Luhn validation would + // tighten this but isn't needed for the demo signal. + r"\b(?:\d[ -]?){13,19}\b".to_string(), + ), + PiiPattern::Email => ( + "email", + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b".to_string(), + ), + PiiPattern::Custom { name, regex } => (name.as_str(), regex.clone()), + }; + let re = Regex::new(&re_str).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{plugin_name}' (apl-pii-scanner): pattern '{name}' \ + failed to compile: {e}" + ), + }) + })?; + out.push((name.to_string(), re)); + } + Ok(out) +} + +#[async_trait] +impl Plugin for PiiScanner { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for PiiScanner { + async fn handle( + &self, + payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let hit = self.first_match(&payload.message); + match (hit, self.typed.mode) { + (None, _) => PluginResult::allow(), + (Some(pattern_name), PiiScanMode::Deny) => { + PluginResult::deny(PluginViolation::new( + "pii.detected", + format!( + "PII pattern '{pattern_name}' detected in request \ + args — refusing to forward to downstream" + ), + )) + } + (Some(_), PiiScanMode::Redact) => { + let mut updated = payload.clone(); + self.redact_message(&mut updated.message); + PluginResult::modify_payload(updated) + } + } + } +} + +// Silence unused-import in case a feature is added later that needs +// Arc — kept for parity with how other crates structure their imports. +#[allow(dead_code)] +fn _force_link_arc(_: Arc<()>) {} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::{Role, ToolCall}; + use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + use serde_json::json; + use std::collections::HashMap; + + fn cfg(detect: Vec, mode: PiiScanMode) -> PluginConfig { + let cfg_json = serde_json::to_value(PiiScannerConfig { detect, mode }).unwrap(); + PluginConfig { + name: "pii-scan".into(), + kind: "test".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(cfg_json), + ..Default::default() + } + } + + fn message_with_args(args: HashMap) -> MessagePayload { + MessagePayload { + message: Message::with_content( + Role::User, + vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "1".into(), + name: "send_email".into(), + arguments: args, + namespace: None, + }, + }], + ), + } + } + + #[tokio::test] + async fn ssn_in_args_denied() { + let p = PiiScanner::new(cfg(vec![PiiPattern::Ssn], PiiScanMode::Deny)).unwrap(); + let payload = message_with_args(HashMap::from([ + ("body".to_string(), json!("Her SSN is 555-12-3456")), + ])); + let mut ctx = PluginContext::default(); + let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; + assert!(!r.continue_processing, "should deny"); + let v = r.violation.expect("violation present"); + assert_eq!(v.code, "pii.detected"); + assert!(v.reason.contains("ssn")); + } + + #[tokio::test] + async fn clean_args_allowed() { + let p = PiiScanner::new(cfg(vec![PiiPattern::Ssn], PiiScanMode::Deny)).unwrap(); + let payload = message_with_args(HashMap::from([ + ("body".to_string(), json!("Quarterly compensation review summary.")), + ])); + let mut ctx = PluginContext::default(); + let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; + assert!(r.continue_processing); + assert!(r.modified_payload.is_none()); + } + + #[tokio::test] + async fn redact_mode_rewrites_value() { + let p = PiiScanner::new(cfg(vec![PiiPattern::Ssn], PiiScanMode::Redact)).unwrap(); + let payload = message_with_args(HashMap::from([ + ("body".to_string(), json!("555-12-3456")), + ("subject".to_string(), json!("payroll question")), + ])); + let mut ctx = PluginContext::default(); + let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; + assert!(r.continue_processing, "redact allows; doesn't deny"); + let modified = r.modified_payload.expect("payload was modified"); + let args = match &modified.message.content[0] { + ContentPart::ToolCall { content } => &content.arguments, + _ => panic!("expected ToolCall"), + }; + assert_eq!(args["body"], json!("[PII]")); + // Untouched fields preserved. + assert_eq!(args["subject"], json!("payroll question")); + } + + #[tokio::test] + async fn custom_pattern() { + let p = PiiScanner::new(cfg( + vec![PiiPattern::Custom { + name: "internal_id".into(), + regex: r"^INT-[A-Z0-9]{6}$".into(), + }], + PiiScanMode::Deny, + )) + .unwrap(); + let payload = message_with_args(HashMap::from([ + ("ref".to_string(), json!("INT-ABC123")), + ])); + let mut ctx = PluginContext::default(); + let r = p.handle(&payload, &Extensions::default(), &mut ctx).await; + assert!(!r.continue_processing); + let v = r.violation.expect("violation present"); + assert!(v.reason.contains("internal_id")); + } +} diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 2885700f..abbd5e62 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -29,3 +29,12 @@ futures = { workspace = true } hashbrown = { workspace = true } arc-swap = { workspace = true } wildmatch = { workspace = true } +chrono = { workspace = true } +# Zeroizing wrapper for raw credential material in RawCredentialsExtension. +# `derive` feature pulls the proc-macro so we can `#[derive(Zeroize)]` on +# token-bearing structs in a future slice; for now only the +# `Zeroizing` wrapper is used directly. +zeroize = { version = "1.8", features = ["zeroize_derive"] } +# Shared concurrency primitive used by `executor::run_concurrent_phase` +# (and apl-core's `Effect::Parallel`). Leaf crate, no cycles back here. +cpex-orchestration = { path = "../cpex-orchestration" } diff --git a/crates/cpex-core/src/cmf/constants.rs b/crates/cpex-core/src/cmf/constants.rs index 12a8ac5e..454ec7b2 100644 --- a/crates/cpex-core/src/cmf/constants.rs +++ b/crates/cpex-core/src/cmf/constants.rs @@ -63,3 +63,34 @@ pub const FIELD_TAGS: &str = "tags"; // OPA envelope pub const FIELD_OPA_INPUT: &str = "input"; + +// --------------------------------------------------------------------------- +// Entity type identifiers — used in MetaExtension.entity_type and as the +// keys for `global.defaults` per-entity-type policy groups. These are the +// MCP entity taxonomy: tools (callable functions), LLMs (model +// invocations), prompts (template fills), resources (URI fetches). +// --------------------------------------------------------------------------- + +pub const ENTITY_TOOL: &str = "tool"; +pub const ENTITY_LLM: &str = "llm"; +pub const ENTITY_PROMPT: &str = "prompt"; +pub const ENTITY_RESOURCE: &str = "resource"; + +// --------------------------------------------------------------------------- +// CMF hook names — the canonical names plugins register under and hosts +// pass to `PluginManager::invoke_named::(...)`. Two per entity +// type — pre-invocation (called from APL's policy / args phase) and +// post-invocation (called from APL's post_policy / result phase). +// +// Used as keys in `hooks::metadata`'s routing table and from plugin +// declarations. +// --------------------------------------------------------------------------- + +pub const HOOK_CMF_TOOL_PRE_INVOKE: &str = "cmf.tool_pre_invoke"; +pub const HOOK_CMF_TOOL_POST_INVOKE: &str = "cmf.tool_post_invoke"; +pub const HOOK_CMF_LLM_INPUT: &str = "cmf.llm_input"; +pub const HOOK_CMF_LLM_OUTPUT: &str = "cmf.llm_output"; +pub const HOOK_CMF_PROMPT_PRE_INVOKE: &str = "cmf.prompt_pre_invoke"; +pub const HOOK_CMF_PROMPT_POST_INVOKE: &str = "cmf.prompt_post_invoke"; +pub const HOOK_CMF_RESOURCE_PRE_FETCH: &str = "cmf.resource_pre_fetch"; +pub const HOOK_CMF_RESOURCE_POST_FETCH: &str = "cmf.resource_post_fetch"; diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs index b2bad350..6a13a2ec 100644 --- a/crates/cpex-core/src/cmf/message.rs +++ b/crates/cpex-core/src/cmf/message.rs @@ -66,6 +66,20 @@ impl Message { } } + /// Create a message from an arbitrary list of typed content + /// parts. The schema version is set from `SCHEMA_VERSION` — + /// callers never hardcode it. Use this when the content isn't a + /// single text blob (tool calls, prompt requests, resource refs, + /// multimodal mixes). + pub fn with_content(role: Role, content: Vec) -> Self { + Self { + schema_version: super::constants::SCHEMA_VERSION.to_string(), + role, + content, + channel: None, + } + } + /// Extract all text content from the message. /// /// Concatenates text from all `Text` content parts. diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 89d962a2..6eca89a8 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -159,6 +159,16 @@ pub struct GlobalConfig { /// Keys are `tool`, `resource`, `prompt`, `llm`. #[serde(default)] pub defaults: HashMap, + + /// Global identity dispatch list. Inherited by every route as + /// the first layer of identity resolution. Routes can append + /// to it (additive, the default) or replace it (with + /// `identity.replace_inherited: true` on the route). + /// + /// Same YAML shape as the route-level `identity:` block — see + /// `RouteEntry.identity` for the accepted forms. + #[serde(default, deserialize_with = "deserialize_route_identity")] + pub identity: Option, } // --------------------------------------------------------------------------- @@ -181,6 +191,14 @@ pub struct PolicyGroup { /// Plugin references to activate when this group matches. #[serde(default)] pub plugins: Vec, + + /// Identity dispatch list contributed by this tag bundle. + /// Inherited by routes that carry this tag in `meta.tags`, + /// stacked between the global identity (first) and the route's + /// own identity (last). Same YAML shape as the route-level + /// `identity:` block. + #[serde(default, deserialize_with = "deserialize_route_identity")] + pub identity: Option, } // --------------------------------------------------------------------------- @@ -262,6 +280,161 @@ pub struct RouteEntry { /// Plugin references to activate for this route. #[serde(default)] pub plugins: Vec, + + /// Identity-resolve dispatch list for this route. **Hook-specific**: + /// applies ONLY to the `identity.resolve` hook, independent of the + /// `plugins:` block above (which is hook-agnostic and means + /// different things depending on whether APL is annotating the + /// route — `identity:` always means "these plugins fire on + /// identity.resolve in this order"). + /// + /// Accepts two YAML shapes; both deserialize to the same IR. + /// See `crate::identity::route_config::RouteIdentityConfig`. + /// + /// ```yaml + /// # List form — common case, additive default + /// identity: + /// - corp-jwt + /// - spiffe-attestor + /// + /// # Object form — when the override flag is needed + /// identity: + /// replace_inherited: true + /// steps: + /// - legacy-basic-auth + /// ``` + #[serde(default, deserialize_with = "deserialize_route_identity")] + pub identity: Option, +} + +// --------------------------------------------------------------------------- +// Custom Deserialize for RouteEntry.identity +// --------------------------------------------------------------------------- + +/// Deserialize `identity:` in a `RouteEntry`. Accepts either a YAML +/// list (treated as additive — `replace_inherited: false`) or a +/// YAML map with `replace_inherited: bool?` + `steps: [...]`. Each +/// step is either a bare plugin name (string) or a map with +/// `name:` + optional `on_error:` / `config:`. Produces friendlier +/// error messages than `#[serde(untagged)]` would. +fn deserialize_route_identity<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use crate::identity::RouteIdentityConfig; + use serde::de::Error; + + // Two-stage: deserialize as opaque YAML so we can discriminate + // list vs object shape with operator-friendly errors. + let raw = match Option::::deserialize(deserializer)? { + None => return Ok(None), + Some(serde_yaml::Value::Null) => return Ok(None), + Some(v) => v, + }; + + let (replace_inherited, raw_steps): (bool, Vec) = match raw { + serde_yaml::Value::Sequence(items) => (false, items), + serde_yaml::Value::Mapping(map) => { + let replace_inherited = match map + .get(serde_yaml::Value::String("replace_inherited".to_string())) + { + Some(v) => v.as_bool().ok_or_else(|| { + D::Error::custom("`identity.replace_inherited` must be a boolean") + })?, + None => false, + }; + let steps_val = map + .get(serde_yaml::Value::String("steps".to_string())) + .ok_or_else(|| { + D::Error::custom( + "`identity:` object form requires `steps:` (a list of \ + identity steps); did you mean to write the list form?", + ) + })?; + let items = steps_val + .as_sequence() + .ok_or_else(|| D::Error::custom("`identity.steps` must be a list"))? + .clone(); + (replace_inherited, items) + } + _ => { + return Err(D::Error::custom( + "`identity:` must be a list of steps or an object with \ + `steps:` (and optional `replace_inherited:`)", + )); + } + }; + + let mut steps = Vec::with_capacity(raw_steps.len()); + for (i, raw) in raw_steps.into_iter().enumerate() { + steps.push(parse_identity_step(raw, i).map_err(D::Error::custom)?); + } + + Ok(Some(RouteIdentityConfig { + steps, + replace_inherited, + })) +} + +/// Parse one identity step from raw YAML. Accepts either a bare +/// plugin name (string) or a map with `name:` + optional +/// `on_error:` / `config:` (and any forward-compat extras). +fn parse_identity_step( + raw: serde_yaml::Value, + index: usize, +) -> Result { + use crate::identity::RouteIdentityStep; + + match raw { + serde_yaml::Value::String(name) => { + if name.is_empty() { + return Err(format!( + "identity step [{index}] plugin name cannot be empty" + )); + } + Ok(RouteIdentityStep { + name, + ..Default::default() + }) + } + serde_yaml::Value::Mapping(_) => { + // Lean on serde's derived Deserialize for the map shape — + // `RouteIdentityStep` already handles `name` / `on_error` / + // `config_override` and flattens extras into `extra`. + // Translate the operator-facing key `config` → IR field + // `config_override` (the IR uses a more explicit name to + // distinguish from the plugin's runtime config). + #[derive(serde::Deserialize)] + struct StepYaml { + name: String, + #[serde(default)] + on_error: Option, + #[serde(default)] + config: Option, + #[serde(default, flatten)] + extra: std::collections::HashMap, + } + let parsed: StepYaml = serde_yaml::from_value(raw) + .map_err(|e| format!("identity step [{index}]: {e}"))?; + if parsed.name.is_empty() { + return Err(format!( + "identity step [{index}] `name:` cannot be empty" + )); + } + Ok(RouteIdentityStep { + name: parsed.name, + config_override: parsed.config, + on_error: parsed.on_error, + extra: parsed.extra, + }) + } + _ => Err(format!( + "identity step [{index}] must be a plugin name (string) or a map \ + with `name:` (and optional `on_error:` / `config:`)" + )), + } } // --------------------------------------------------------------------------- @@ -581,6 +754,107 @@ pub fn resolve_plugins_for_entity( deduped } +/// Resolve the identity-resolve dispatch list for a specific +/// entity. Hook-specific counterpart to [`resolve_plugins_for_entity`] +/// — consults `global.identity`, tag-bundle `identity` blocks, and +/// the route's own `identity:` block to determine which plugins fire +/// on the `identity.resolve` hook for this route. +/// +/// # Inheritance / merge order +/// +/// Layers are stacked **global → tag bundles → route**, in that +/// order. Within tags, the order is determined by the request's +/// `meta.tags` (which combines static route tags + runtime request +/// tags). Each layer is appended to the running list unless the +/// **route's** block has `replace_inherited: true`, in which case +/// inherited layers (global + tags) are dropped and only the route's +/// steps remain. Tag-bundle `replace_inherited` is parsed but not +/// honored — only the route layer can opt out of inheritance. +/// +/// Order matters: returned plugins fire in the order they were +/// merged. The first plugin's resolved `IdentityPayload` flows into +/// the second plugin's input via the executor's Sequential-phase +/// semantics, so global identity contributions land first, then +/// tag-bundle, then route-specific overrides / additions. +/// +/// Per-step `config_override` is surfaced as +/// `ResolvedPlugin.config_overrides` so the standard +/// `filter_entries_by_route` override pathway +/// (`create_override_instance`) applies — same mechanism the +/// `plugins:` block uses. +/// +/// Returns an empty `Vec` when no layer contributed any steps +/// (e.g. anonymous routes that explicitly opt out via +/// `replace_inherited: true` + empty `steps: []`). +pub fn resolve_identity_plugins_for_route( + config: &CpexConfig, + entity_type: &str, + entity_name: &str, + request_scope: Option<&str>, +) -> Vec { + // Route-level block is the override authority. Find the matching + // route up-front; absence means there's no route to inherit + // identity FOR (still consult global identity though, since the + // host might be doing per-route hook routing on entity_type + // alone with no specific route). + let route = find_matching_route(config, entity_type, entity_name, request_scope); + let route_identity = route.and_then(|r| r.identity.as_ref()); + + // Check the override flag before doing any inheritance work — + // if the route opts out, inherited layers are dropped. + let replace_inherited = route_identity + .map(|id| id.replace_inherited) + .unwrap_or(false); + + let mut steps: Vec = Vec::new(); + + if !replace_inherited { + // Global layer first — applies to every route. + if let Some(global_identity) = config.global.identity.as_ref() { + steps.extend(global_identity.steps.iter().cloned()); + } + + // Tag-bundle layers next. Walk the route's tags (static + + // any runtime tags would compose here too, but resolve_* + // currently doesn't take runtime tags as a parameter for + // identity — symmetry with the existing `plugins:` resolver + // would extend the signature; deferred until needed). + if let Some(route) = route { + if let Some(meta) = &route.meta { + for tag in &meta.tags { + if let Some(bundle) = config.global.policies.get(tag) { + if let Some(bundle_identity) = bundle.identity.as_ref() { + steps.extend(bundle_identity.steps.iter().cloned()); + } + } + } + } + } + } + + // Route layer last (or only, when replace_inherited). + if let Some(id) = route_identity { + steps.extend(id.steps.iter().cloned()); + } + + steps + .into_iter() + .map(|step| ResolvedPlugin { + name: step.name.clone(), + // Surface config_override under the `config:` key shape + // that `create_override_instance` already understands — + // it reads `overrides.get("config")` to find the merge + // target. Wrapping like this avoids a special-case path. + config_overrides: step.config_override.as_ref().map(|cfg| { + let mut wrapper = serde_json::Map::new(); + wrapper.insert("config".to_string(), cfg.clone()); + serde_json::Value::Object(wrapper) + }), + when: None, + }) + .collect() +} + /// A resolved plugin with optional config overrides and when clause. #[derive(Debug, Clone)] pub struct ResolvedPlugin { @@ -1294,4 +1568,439 @@ routes: let route = resolved.iter().find(|r| r.name == "route_plugin").unwrap(); assert_eq!(route.when.as_deref(), Some("args.sensitive == true")); } + + // ---- route-level `identity:` block ---- + + #[test] + fn parse_route_identity_list_form() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - corp-jwt + - spiffe-attestor +"#; + let cfg = parse_config(yaml).unwrap(); + let route = &cfg.routes[0]; + let id = route.identity.as_ref().expect("identity present"); + assert!(!id.replace_inherited); + assert_eq!(id.steps.len(), 2); + assert_eq!(id.steps[0].name, "corp-jwt"); + assert!(id.steps[0].config_override.is_none()); + assert!(id.steps[0].on_error.is_none()); + assert_eq!(id.steps[1].name, "spiffe-attestor"); + } + + #[test] + fn parse_route_identity_object_form_carries_replace_inherited() { + let yaml = r#" +plugins: + - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: legacy + identity: + replace_inherited: true + steps: + - legacy-basic-auth +"#; + let cfg = parse_config(yaml).unwrap(); + let id = cfg.routes[0].identity.as_ref().unwrap(); + assert!(id.replace_inherited); + assert_eq!(id.steps.len(), 1); + assert_eq!(id.steps[0].name, "legacy-basic-auth"); + } + + #[test] + fn parse_route_identity_map_step_with_on_error_and_config() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - name: corp-jwt + on_error: deny + config: + audience: my-tool +"#; + let cfg = parse_config(yaml).unwrap(); + let id = cfg.routes[0].identity.as_ref().unwrap(); + let s0 = &id.steps[0]; + assert_eq!(s0.name, "corp-jwt"); + assert_eq!(s0.on_error.as_deref(), Some("deny")); + let cfg_override = s0.config_override.as_ref().expect("config_override set"); + assert_eq!( + cfg_override.get("audience").and_then(|v| v.as_str()), + Some("my-tool"), + ); + } + + #[test] + fn parse_route_identity_mixed_bare_and_map_steps() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - name: corp-jwt + on_error: deny + - spiffe-attestor +"#; + let cfg = parse_config(yaml).unwrap(); + let steps = &cfg.routes[0].identity.as_ref().unwrap().steps; + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].on_error.as_deref(), Some("deny")); + assert!(steps[1].on_error.is_none()); + } + + #[test] + fn parse_route_identity_object_form_without_steps_errors() { + let yaml = r#" +routes: + - tool: bad + identity: + replace_inherited: true +"#; + let err = parse_config(yaml).expect_err("object form requires steps"); + let msg = format!("{err}"); + assert!(msg.contains("requires `steps:`"), "got: {msg}"); + } + + #[test] + fn parse_route_identity_replace_inherited_must_be_boolean() { + let yaml = r#" +routes: + - tool: bad + identity: + replace_inherited: "yes" + steps: + - corp-jwt +"#; + let err = parse_config(yaml).expect_err("replace_inherited must be bool"); + let msg = format!("{err}"); + assert!(msg.contains("boolean"), "got: {msg}"); + } + + #[test] + fn parse_route_identity_empty_step_name_errors() { + let yaml = r#" +routes: + - tool: bad + identity: + - "" +"#; + let err = parse_config(yaml).expect_err("empty step name should fail"); + let msg = format!("{err}"); + assert!(msg.contains("empty"), "got: {msg}"); + } + + #[test] + fn parse_route_identity_scalar_shape_errors() { + let yaml = r#" +routes: + - tool: bad + identity: 42 +"#; + let err = parse_config(yaml).expect_err("scalar identity should fail"); + let msg = format!("{err}"); + assert!(msg.contains("list of steps"), "got: {msg}"); + } + + // ---- resolve_identity_plugins_for_route ---- + + #[test] + fn resolve_identity_returns_empty_when_no_route_matches() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - corp-jwt +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "unmatched_tool", None); + assert!(resolved.is_empty()); + } + + #[test] + fn resolve_identity_returns_empty_when_route_has_no_identity_block() { + let yaml = r#" +plugins: + - { name: rate_limiter, kind: builtin, hooks: [tool_pre_invoke] } +routes: + - tool: get_weather + plugins: + - rate_limiter +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + assert!(resolved.is_empty()); + } + + #[test] + fn resolve_identity_preserves_declared_order() { + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: spiffe-attestor, kind: builtin, hooks: [identity.resolve] } + - { name: agent-context, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - spiffe-attestor + - corp-jwt + - agent-context +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["spiffe-attestor", "corp-jwt", "agent-context"]); + } + + #[test] + fn resolve_identity_per_step_config_override_surfaces_for_create_override_instance() { + // `create_override_instance` reads `overrides.get("config")` + // — `resolve_identity_plugins_for_route` wraps the step's + // `config_override` under that key so the existing override + // pathway picks it up without a special case. + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + identity: + - name: corp-jwt + config: + audience: my-tool +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + assert_eq!(resolved.len(), 1); + let overrides = resolved[0] + .config_overrides + .as_ref() + .expect("overrides wrapped"); + let config = overrides.get("config").expect("config key present"); + assert_eq!(config.get("audience").and_then(|v| v.as_str()), Some("my-tool")); + } + + // ---- Slice C: global + tag-bundle inheritance ---- + + #[test] + fn resolve_identity_includes_global_layer_when_route_has_no_block() { + // global.identity defined; route declares no identity. The + // route should inherit the global steps unchanged. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt +routes: + - tool: get_weather +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["corp-jwt"]); + } + + #[test] + fn resolve_identity_appends_route_steps_after_global_by_default() { + // global → route is the standard stacking. Route's `identity:` + // is the list form (implicit replace_inherited=false), so + // its steps APPEND after the global's. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: agent-context, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt +routes: + - tool: get_weather + identity: + - agent-context +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_weather", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["corp-jwt", "agent-context"]); + } + + #[test] + fn resolve_identity_stacks_global_then_tag_bundle_then_route() { + // Full stack: global + tag bundle + route, all contributing. + // Order is global first, then the matching tag's bundle, + // then the route's own steps. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } + - { name: agent-context, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml +routes: + - tool: get_compensation + meta: + tags: [finance] + identity: + - agent-context +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "get_compensation", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["corp-jwt", "workday-saml", "agent-context"]); + } + + #[test] + fn resolve_identity_replace_inherited_drops_global_and_tag_layers() { + // Route says `replace_inherited: true` → only route's steps + // survive. Global and tag-bundle contributions get dropped. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } + - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } + - { name: legacy-basic-auth, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml +routes: + - tool: legacy_endpoint + meta: + tags: [finance] + identity: + replace_inherited: true + steps: + - legacy-basic-auth +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "legacy_endpoint", None); + let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["legacy-basic-auth"]); + } + + #[test] + fn resolve_identity_replace_inherited_with_empty_steps_yields_nothing() { + // `replace_inherited: true` + `steps: []` is the explicit + // opt-out — anonymous routes use this to suppress inherited + // identity entirely. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +global: + identity: + - corp-jwt +routes: + - tool: anonymous_endpoint + identity: + replace_inherited: true + steps: [] +"#; + let cfg = parse_config(yaml).unwrap(); + let resolved = + resolve_identity_plugins_for_route(&cfg, "tool", "anonymous_endpoint", None); + assert!(resolved.is_empty()); + } + + #[test] + fn resolve_identity_tag_bundle_only_when_route_carries_the_tag() { + // The tag bundle's identity only contributes when the route + // declares the matching tag — not for unrelated routes. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - { name: workday-saml, kind: builtin, hooks: [identity.resolve] } +global: + policies: + finance: + identity: + - workday-saml +routes: + - tool: with_tag + meta: + tags: [finance] + - tool: without_tag +"#; + let cfg = parse_config(yaml).unwrap(); + + let tagged = + resolve_identity_plugins_for_route(&cfg, "tool", "with_tag", None); + assert_eq!( + tagged.iter().map(|r| r.name.as_str()).collect::>(), + vec!["workday-saml"], + ); + + let untagged = + resolve_identity_plugins_for_route(&cfg, "tool", "without_tag", None); + assert!(untagged.is_empty(), "tag bundle should NOT apply to untagged routes"); + } + + #[test] + fn resolve_identity_scope_filtering_matches_other_route_resolution() { + // Identity routing uses the same `find_matching_route` + // scope-aware matcher as the generic `plugins:` resolution, + // so requests for a different scope shouldn't pick up + // identity from this route. + let yaml = r#" +plugins: + - { name: corp-jwt, kind: builtin, hooks: [identity.resolve] } +routes: + - tool: get_weather + meta: + scope: tenant-a + identity: + - corp-jwt +"#; + let cfg = parse_config(yaml).unwrap(); + let matching = resolve_identity_plugins_for_route( + &cfg, + "tool", + "get_weather", + Some("tenant-a"), + ); + assert_eq!(matching.len(), 1); + + let non_matching = resolve_identity_plugins_for_route( + &cfg, + "tool", + "get_weather", + Some("tenant-b"), + ); + assert!(non_matching.is_empty()); + } } diff --git a/crates/cpex-core/src/delegation/hook.rs b/crates/cpex-core/src/delegation/hook.rs new file mode 100644 index 00000000..9b001514 --- /dev/null +++ b/crates/cpex-core/src/delegation/hook.rs @@ -0,0 +1,86 @@ +// Location: ./crates/cpex-core/src/delegation/hook.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `TokenDelegateHook` — the `HookTypeDef` marker for the +// TokenDelegate hook family. Plugins implement +// `HookHandler`; outbound code dispatches into it +// to mint a downstream-scoped credential for the call it's about to +// make. +// +// Single hook name (for now): `"token.delegate"`. Future variants +// with the same payload shape — e.g. `"token.refresh"` for a +// refresh-token specific flow — could share `TokenDelegateHook` via +// multi-name registration. Variants with different payloads get +// their own hook type rather than reusing this one. + +use crate::hooks::trait_def::PluginResult; + +use super::payload::DelegationPayload; + +/// Primary hook name for TokenDelegate handlers. +pub const HOOK_TOKEN_DELEGATE: &str = "token.delegate"; + +crate::define_hook! { + /// Token-delegation hook. + /// + /// **Payload** ([`DelegationPayload`]) — unified input + accumulator. + /// The outbound caller (typically a forwarding-proxy plugin) + /// populates the input fields (`bearer_token`, `target_name`, + /// `target_audience`, `required_permissions`, …) and invokes the + /// hook; handlers populate the output fields + /// (`delegated_token`, `delegation_update`, `metadata`) on clones + /// of the running payload. Input fields are private and read + /// through accessors — handlers cannot mutate them even on a + /// clone, so the delegation context is canonical across the chain. + /// + /// **Result** ([`PluginResult`][PluginResult]) + /// — the executor's standard envelope. `modified_payload` + /// carries the updated payload. `continue_processing = false` + /// halts the pipeline (handler decided no credential can be + /// minted — e.g. the inbound token's scopes don't cover the + /// target's required permissions). + /// + /// **Threading.** Sequential-phase semantics already thread + /// handler N's `modified_payload` into handler N+1's input, so + /// the chain's natural behavior is "each handler sees the prior + /// handler's contributions in the running payload." Most + /// deployments will register exactly one TokenDelegate handler + /// (RFC 8693 exchanger, UCAN minter, …), but chaining works for + /// hybrid setups — e.g. a passthrough fallback that fires only + /// when the primary exchanger declined. + /// + /// **Handler signature:** + /// + /// ```rust,ignore + /// impl HookHandler for RfcExchanger { + /// async fn handle( + /// &self, + /// payload: &DelegationPayload, + /// _ext: &Extensions, + /// _ctx: &mut PluginContext, + /// ) -> PluginResult { + /// let minted = self + /// .exchange(payload.bearer_token(), payload.target_audience()) + /// .await?; + /// let mut updated = payload.clone(); + /// updated.delegated_token = Some(minted); + /// PluginResult::modify_payload(updated) + /// } + /// } + /// ``` + /// + /// **Registration:** + /// `manager.register_handler_for_names::(plugin, config, &["token.delegate"])`. + /// `register_handler::` alone registers + /// under the marker's `NAME` ("token") which is the hook family, + /// not the specific hook name — `register_handler_for_names` + /// (or the unified-name path) is the right call. + /// + /// [PluginResult]: crate::hooks::trait_def::PluginResult + TokenDelegateHook, "token.delegate" => { + payload: DelegationPayload, + result: PluginResult, + } +} diff --git a/crates/cpex-core/src/delegation/mod.rs b/crates/cpex-core/src/delegation/mod.rs new file mode 100644 index 00000000..af86a83c --- /dev/null +++ b/crates/cpex-core/src/delegation/mod.rs @@ -0,0 +1,21 @@ +// Location: ./crates/cpex-core/src/delegation/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Token-delegation hook family — TokenDelegate. +// +// Mirrors the identity/ module layout: the hook marker + handler +// trait machinery (provided by cpex-core's generic hooks layer) +// plus the hook-specific payload + result types. +// +// Sub-step A scope: data shapes + host helpers — no executor +// wiring (that's free via `mgr.invoke_named::`), +// no TokenCacheControl trait (that lands in a follow-up slice with +// the cache infrastructure). + +pub mod hook; +pub mod payload; + +pub use hook::{TokenDelegateHook, HOOK_TOKEN_DELEGATE}; +pub use payload::{AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType}; diff --git a/crates/cpex-core/src/delegation/payload.rs b/crates/cpex-core/src/delegation/payload.rs new file mode 100644 index 00000000..6328d088 --- /dev/null +++ b/crates/cpex-core/src/delegation/payload.rs @@ -0,0 +1,694 @@ +// Location: ./crates/cpex-core/src/delegation/payload.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `DelegationPayload` — the unified state struct threaded through the +// TokenDelegate hook chain. Same input/output split pattern as +// `IdentityPayload` (slice 2): +// +// * **Input** (private — host-supplied, never mutated by handlers) — +// `bearer_token`, `target_name`, `target_type`, `target_audience`, +// `required_permissions`, `trust_domain`, `auth_enforced_by`, +// `route_attenuation`. Set once at the call site that needs to mint +// a downstream credential. Privacy is enforced at the module +// boundary: external code reads through accessors and has no +// setters or mutable field access. +// +// * **Accumulating output** (`pub` fields) — `delegated_token` and +// `delegation_update`. Handlers clone the payload, populate these, +// return the updated payload via `PluginResult::modify_payload`. +// +// # Where this hook fits +// +// IdentityResolve (slice 2) is *inbound* — validates the caller's +// credentials at request entry, populates `security.subject` / +// `security.client` / `security.caller_workload`. TokenDelegate is +// *outbound* — when a plugin (typically a forwarding proxy) needs to +// make a downstream call to a tool or agent, it asks for an +// appropriately-scoped credential for that target. A handler (RFC +// 8693 token exchanger, UCAN minter, passthrough) produces the +// minted token; the framework stashes it in +// `Extensions.raw_credentials.delegated_tokens` for the proxy plugin +// to attach on the upstream request. +// +// # Caching +// +// Not in this slice. The spec describes a `TokenCacheControl` trait +// at §9.8 that wraps this hook with `get_or_mint(audience, scopes)` +// semantics — outbound callers ask the trait for a token; the trait +// hits the cache first and only dispatches through the hook on cache +// miss. That layer lives one slice later. For now, every +// `mgr.invoke_named::(...)` re-runs the chain. +// +// # Rejection +// +// Same as IdentityResolve: handlers reject via +// `PluginResult::deny(PluginViolation::new(code, reason))`. The +// executor halts the chain; no later handler runs and the request +// fails with the violation surfaced to the host. No `rejected` flag +// on the payload. + +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::executor::PipelineResult; +use crate::extensions::raw_credentials::DelegationMode; +use crate::extensions::{ + DelegationExtension, Extensions, RawCredentialsExtension, RawDelegatedToken, +}; +use crate::impl_plugin_payload; + +/// Kind of downstream entity the credential is being minted for. +/// `Custom(String)` is the escape hatch for host-defined entity +/// types beyond the well-known shapes. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetType { + /// A tool invocation (MCP tool, function call). + Tool, + /// An agent — another LLM-driven actor. + Agent, + /// A static resource (file, URL, document store entry). + Resource, + /// A service (microservice, internal API). + Service, + /// Operator-defined target kind. + #[serde(untagged)] + Custom(String), +} + +impl Default for TargetType { + fn default() -> Self { + TargetType::Tool + } +} + +/// Who's responsible for enforcing authorization on the downstream +/// call. From the `ObjectSecurityProfile` of the target. Determines +/// whether the gateway brokers credentials (`Caller`), trusts the +/// target to handle auth itself (`Target`), or both layers enforce +/// (`Both`). +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthEnforcedBy { + /// Caller (the gateway / our process) enforces — typical for + /// internal services that trust the gateway's authorization + /// decision. + Caller, + /// Target enforces — typical for external services with their + /// own access control. We may still attach credentials but the + /// downstream makes the final allow/deny decision. + Target, + /// Both layers enforce — defense in depth. + Both, +} + +impl Default for AuthEnforcedBy { + fn default() -> Self { + AuthEnforcedBy::Caller + } +} + +/// Scope-attenuation config carried from the route DSL. Lets the +/// route author narrow what the minted credential is allowed to do +/// beyond the broad authorization the inbound credential carried. +/// +/// `resource_template` is a templated URI (e.g. +/// `"hr://employees/{{ args.employee_id }}"`) that the framework +/// renders against request-time arguments before passing into the +/// minted token's scope claim. v0 doesn't include a template +/// renderer — handlers receive the raw template string and render +/// themselves; a framework-side renderer can come later. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AttenuationConfig { + /// Specific capabilities the route author wants granted. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, + + /// URI template for the resource being accessed. Unrendered — + /// handlers substitute `{{ args.* }}` placeholders themselves + /// using request context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_template: Option, + + /// Actions allowed on the resource (read / write / delete / + /// custom verbs). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec, + + /// Token lifetime override in seconds. `None` lets the handler + /// pick its default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, +} + +/// State threaded through the TokenDelegate hook chain. +/// +/// See the module-level docs for the input/output split. Input +/// fields are private (set once via the constructor + builders, +/// never mutated). Output fields are `pub` (handlers populate on +/// clones and return the updated payload). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationPayload { + // ----- Input (private — caller-supplied, never mutated by handlers) ----- + /// The caller's current credential — the one a token-exchange + /// handler will swap for a downstream-scoped credential. Cleared + /// on drop via `Zeroizing`. `#[serde(skip)]` — never appears in + /// serialized output. + #[serde(skip)] + bearer_token: Zeroizing, + + /// Name of the tool / agent / resource being called. + target_name: String, + + /// Kind of downstream entity. + #[serde(default)] + target_type: TargetType, + + /// Audience URI for the target, from route config. + #[serde(default, skip_serializing_if = "Option::is_none")] + target_audience: Option, + + /// Required permissions from the target's `ObjectSecurityProfile`. + /// Handlers must produce a credential that grants these (or fail). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + required_permissions: Vec, + + /// Target's trust domain (SPIFFE-style) — useful for handlers + /// that mint workload-identity tokens. + #[serde(default, skip_serializing_if = "Option::is_none")] + trust_domain: Option, + + /// Who's responsible for enforcing authorization. + #[serde(default)] + auth_enforced_by: AuthEnforcedBy, + + /// Scope-attenuation config from the route DSL. + #[serde(default, skip_serializing_if = "Option::is_none")] + route_attenuation: Option, + + // ----- Output (pub — handlers populate via direct assignment on clones) ----- + /// The minted outbound credential. `None` until a handler + /// produces one. Carries the raw bytes (cleared on drop), the + /// header the proxy plugin should attach it under, the + /// audience it was minted for, the effective scopes, and the + /// expiry timestamp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegated_token: Option, + + /// Chain update — the new hop to append to the running + /// `DelegationExtension`. Handlers append themselves to the + /// chain so audit / policy can trace who delegated to whom. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation_update: Option, + + /// What kind of principal the minted token represents. + /// Handlers populating `delegated_token` should also set this + /// so `apply_to_extensions` keys the cache correctly: + /// + /// * `OnBehalfOfUser` — token speaks for the original user + /// (RFC 8693 on-behalf-of / actor-token, UCAN delegation). + /// Standard flow; cache key includes the user's subject id. + /// * `AsGateway` — token speaks for the gateway itself. + /// User identity is conveyed through separate context. + /// Cache key falls back to the gateway's identity. + /// + /// `None` defaults to `OnBehalfOfUser` for backward compatibility + /// with handlers that don't yet populate the field. Long-term, + /// handlers should always set this explicitly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation_mode: Option, + + /// Resolution timestamp. Audit-useful. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minted_at: Option>, + + /// Optional metadata produced by the handler (telemetry, + /// diagnostics). Not load-bearing for policy. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, +} + +impl DelegationPayload { + /// Construct a payload with the required input fields populated. + /// The most common entry point — outbound callers (forwarding + /// proxies, etc.) build this once per delegation point. Optional + /// input slots are set via the `.with_*` builders below; output + /// fields start as `None` / empty and accumulate as handlers run. + pub fn new( + bearer_token: impl Into, + target_name: impl Into, + ) -> Self { + Self { + bearer_token: Zeroizing::new(bearer_token.into()), + target_name: target_name.into(), + target_type: TargetType::Tool, + target_audience: None, + required_permissions: Vec::new(), + trust_domain: None, + auth_enforced_by: AuthEnforcedBy::Caller, + route_attenuation: None, + delegated_token: None, + delegation_update: None, + delegation_mode: None, + minted_at: None, + metadata: HashMap::new(), + } + } + + // -------- Input builders -------- + + pub fn with_target_type(mut self, t: TargetType) -> Self { + self.target_type = t; + self + } + + pub fn with_target_audience(mut self, aud: impl Into) -> Self { + self.target_audience = Some(aud.into()); + self + } + + pub fn with_required_permissions(mut self, perms: Vec) -> Self { + self.required_permissions = perms; + self + } + + pub fn with_trust_domain(mut self, td: impl Into) -> Self { + self.trust_domain = Some(td.into()); + self + } + + pub fn with_auth_enforced_by(mut self, who: AuthEnforcedBy) -> Self { + self.auth_enforced_by = who; + self + } + + pub fn with_route_attenuation(mut self, cfg: AttenuationConfig) -> Self { + self.route_attenuation = Some(cfg); + self + } + + // -------- Input read accessors -------- + + /// The caller's bearer token — borrowed, no way to move or + /// replace the underlying `Zeroizing` through this. + pub fn bearer_token(&self) -> &str { + &self.bearer_token + } + + pub fn target_name(&self) -> &str { + &self.target_name + } + + pub fn target_type(&self) -> &TargetType { + &self.target_type + } + + pub fn target_audience(&self) -> Option<&str> { + self.target_audience.as_deref() + } + + pub fn required_permissions(&self) -> &[String] { + &self.required_permissions + } + + pub fn trust_domain(&self) -> Option<&str> { + self.trust_domain.as_deref() + } + + pub fn auth_enforced_by(&self) -> AuthEnforcedBy { + self.auth_enforced_by + } + + pub fn route_attenuation(&self) -> Option<&AttenuationConfig> { + self.route_attenuation.as_ref() + } + + // -------- Output helpers -------- + + /// Layer another payload's *output* fields onto this one's, + /// following "Some replaces None, last write wins per slot." + /// Input fields are not touched — the running payload's input + /// is canonical for the whole chain. + /// + /// Metadata is merged (not replaced) — `other`'s keys overlay + /// `self`'s, matching the "later handler additively contributes + /// telemetry" expectation. + pub fn merge(&mut self, other: DelegationPayload) { + if other.delegated_token.is_some() { + self.delegated_token = other.delegated_token; + } + if other.delegation_update.is_some() { + self.delegation_update = other.delegation_update; + } + if other.delegation_mode.is_some() { + self.delegation_mode = other.delegation_mode; + } + if other.minted_at.is_some() { + self.minted_at = other.minted_at; + } + for (k, v) in other.metadata { + self.metadata.insert(k, v); + } + } + + // -------- Host-side application helpers -------- + + /// Pull the resolved `DelegationPayload` out of a `PipelineResult` + /// returned by `mgr.invoke_named::(...)`. + /// Returns `None` when the pipeline was denied or when the result's + /// payload wasn't a `DelegationPayload`. Same contract as + /// `IdentityPayload::from_pipeline_result`. + pub fn from_pipeline_result(result: &PipelineResult) -> Option { + result + .modified_payload + .as_ref() + .and_then(|p| p.as_any().downcast_ref::()) + .cloned() + } + + /// Apply this payload's resolved output slots back into an + /// `Extensions` container. Returns a new `Extensions` ready to + /// hand to the outbound proxy plugin that will attach the minted + /// credential and forward. + /// + /// Application rules: + /// + /// - **`raw_credentials.delegated_tokens`** — if the payload + /// carries a `delegated_token`, it's inserted into the map under + /// a `DelegationKey` derived from the input fields (audience, + /// subject not yet plumbed — see "Open work" below). Pre-existing + /// delegated tokens are preserved. + /// - **`delegation`** — `delegation_update` overlays on top of + /// the existing chain (Some replaces None / appends). + /// + /// # Open work + /// + /// The `DelegationKey` we synthesize here uses only fields the + /// payload knows about — `audience`, `scopes` (derived from the + /// effective scopes on the minted token), `mode`. The `subject_id` + /// field of `DelegationKey` requires reading the request's + /// `Extensions.security.subject.id`; we plumb that lookup here + /// rather than asking outbound callers to thread the subject + /// through. If `security.subject.id` is absent the key falls back + /// to the empty string — flagged via tracing but not fatal, + /// because some delegation flows are gateway-as-principal + /// (AsGateway mode) and don't need a subject. + pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions { + if let Some(ref token) = self.delegated_token { + use crate::extensions::raw_credentials::DelegationKey; + + let subject_id = ext + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.clone()) + .unwrap_or_default(); + + // Default to OnBehalfOfUser when the handler didn't + // populate `delegation_mode`. Backward-compatible with + // handlers from sub-step B; future handlers should + // populate the field explicitly. + let mode = self + .delegation_mode + .clone() + .unwrap_or(DelegationMode::OnBehalfOfUser); + let key = DelegationKey { + subject_id, + audience: token.audience.clone(), + scopes: token.scopes.clone(), + mode, + }; + + let mut raw = ext + .raw_credentials + .as_ref() + .map(|arc| (**arc).clone()) + .unwrap_or_else(RawCredentialsExtension::default); + raw.delegated_tokens.insert(key, token.clone()); + ext.raw_credentials = Some(Arc::new(raw)); + } + + if let Some(ref update) = self.delegation_update { + // Replace wholesale for v0. A per-hop append semantics + // would deep-merge the chain, but `DelegationExtension`'s + // append rules live with the type — handlers that want + // to add a hop produce a `DelegationExtension` containing + // the new hop in its chain. + ext.delegation = Some(Arc::new(update.clone())); + } + + ext + } +} + +impl_plugin_payload!(DelegationPayload); + +#[cfg(test)] +mod tests { + use super::*; + use crate::extensions::raw_credentials::RawDelegatedToken; + + #[test] + fn bearer_token_does_not_serialize() { + let p = DelegationPayload::new("eyJ.caller.tok", "get_compensation"); + let json = serde_json::to_string(&p).unwrap(); + assert!( + !json.contains("eyJ.caller.tok"), + "bearer_token leaked into serialized form: {}", + json, + ); + assert!(json.contains("get_compensation")); + } + + #[test] + fn deserialize_yields_empty_bearer_token() { + let json = r#"{"target_name":"get_compensation"}"#; + let p: DelegationPayload = serde_json::from_str(json).unwrap(); + assert_eq!(p.bearer_token(), ""); + assert_eq!(p.target_name(), "get_compensation"); + } + + #[test] + fn input_builders_chain() { + let p = DelegationPayload::new("tok", "get_compensation") + .with_target_type(TargetType::Tool) + .with_target_audience("https://hr.example.com") + .with_required_permissions(vec!["read:compensation".into()]) + .with_trust_domain("hr.example.com") + .with_auth_enforced_by(AuthEnforcedBy::Target) + .with_route_attenuation(AttenuationConfig { + capabilities: vec!["read:compensation".into()], + resource_template: Some("hr://employees/{{ args.employee_id }}".into()), + actions: vec!["read".into()], + ttl_seconds: Some(60), + }); + assert_eq!(p.bearer_token(), "tok"); + assert_eq!(p.target_name(), "get_compensation"); + assert_eq!(p.target_audience(), Some("https://hr.example.com")); + assert_eq!(p.required_permissions(), &["read:compensation".to_string()]); + assert_eq!(p.trust_domain(), Some("hr.example.com")); + assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Target); + let att = p.route_attenuation().unwrap(); + assert_eq!(att.ttl_seconds, Some(60)); + assert_eq!(att.actions, vec!["read"]); + } + + #[test] + fn target_type_custom_round_trips() { + let t = TargetType::Custom("workflow".into()); + let json = serde_json::to_string(&t).unwrap(); + let back: TargetType = serde_json::from_str(&json).unwrap(); + assert_eq!(t, back); + } + + #[test] + fn handler_can_populate_output_on_clone() { + // Typical handler pattern: clone running payload, set + // delegated_token + delegation_update, return. + let original = DelegationPayload::new("caller-tok", "downstream-tool"); + let mut updated = original.clone(); + updated.delegated_token = Some(RawDelegatedToken::new( + "minted-bytes", + "Authorization", + "https://api.example.com", + vec!["read".into()], + Utc::now(), + )); + // Input survives the clone. + assert_eq!(updated.bearer_token(), "caller-tok"); + assert_eq!(updated.target_name(), "downstream-tool"); + // Output populated. + assert!(updated.delegated_token.is_some()); + // Original untouched. + assert!(original.delegated_token.is_none()); + } + + #[test] + fn merge_overlays_outputs() { + let mut base = DelegationPayload::new("tok", "tool"); + base.metadata + .insert("attempt".into(), serde_json::json!(1)); + let mut overlay = DelegationPayload::new("", ""); + overlay.delegated_token = Some(RawDelegatedToken::new( + "x", + "Authorization", + "aud", + vec![], + Utc::now(), + )); + overlay + .metadata + .insert("latency_ms".into(), serde_json::json!(42)); + base.merge(overlay); + assert!(base.delegated_token.is_some()); + // Metadata merged additively — both keys present. + assert!(base.metadata.contains_key("attempt")); + assert!(base.metadata.contains_key("latency_ms")); + } + + #[test] + fn apply_to_extensions_writes_delegated_token_keyed_by_audience() { + use crate::extensions::raw_credentials::DelegationMode; + use crate::extensions::SubjectExtension; + + let mut p = DelegationPayload::new("tok", "get_compensation"); + p.delegated_token = Some(RawDelegatedToken::new( + "minted-jwt", + "Authorization", + "https://hr.example.com", + vec!["read:compensation".into()], + Utc::now() + chrono::Duration::seconds(300), + )); + + // Pre-existing subject in extensions — DelegationKey.subject_id + // should pull from there. + let initial_ext = Extensions { + security: Some(Arc::new(crate::extensions::SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + + let updated = p.apply_to_extensions(initial_ext); + let raw = updated.raw_credentials.as_ref().unwrap(); + assert_eq!(raw.delegated_tokens.len(), 1); + + // Look up by the synthesized key. + let expected_key = crate::extensions::raw_credentials::DelegationKey { + subject_id: "alice".into(), + audience: "https://hr.example.com".into(), + scopes: vec!["read:compensation".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + assert!(raw.delegated_tokens.contains_key(&expected_key)); + } + + #[test] + fn apply_to_extensions_respects_explicit_delegation_mode() { + // Handler that mints an AsGateway-mode token (gateway-as-principal + // flow). The key in `delegated_tokens` should carry AsGateway, + // not the default OnBehalfOfUser. + let mut p = DelegationPayload::new("tok", "tool"); + p.delegated_token = Some(RawDelegatedToken::new( + "gateway-token", + "Authorization", + "https://downstream.example.com", + vec!["service:call".into()], + Utc::now(), + )); + p.delegation_mode = Some( + crate::extensions::raw_credentials::DelegationMode::AsGateway, + ); + + let updated = p.apply_to_extensions(Extensions::default()); + let raw = updated.raw_credentials.as_ref().unwrap(); + let key = raw.delegated_tokens.keys().next().unwrap(); + assert!(matches!( + key.mode, + crate::extensions::raw_credentials::DelegationMode::AsGateway + )); + } + + #[test] + fn apply_to_extensions_defaults_delegation_mode_when_unset() { + // Handler that didn't populate delegation_mode — apply should + // use OnBehalfOfUser as the safe default. + let mut p = DelegationPayload::new("tok", "tool"); + p.delegated_token = Some(RawDelegatedToken::new( + "user-token", + "Authorization", + "https://aud.example.com", + vec!["read".into()], + Utc::now(), + )); + // delegation_mode left None. + let updated = p.apply_to_extensions(Extensions::default()); + let raw = updated.raw_credentials.as_ref().unwrap(); + let key = raw.delegated_tokens.keys().next().unwrap(); + assert!(matches!( + key.mode, + crate::extensions::raw_credentials::DelegationMode::OnBehalfOfUser + )); + } + + #[test] + fn merge_threads_delegation_mode_through_chain() { + // Handler A leaves delegation_mode unset; handler B sets it. + // After merge, the accumulator should carry handler B's mode. + let mut base = DelegationPayload::new("tok", "tool"); + // base.delegation_mode = None + let mut overlay = DelegationPayload::new("", ""); + overlay.delegation_mode = Some( + crate::extensions::raw_credentials::DelegationMode::AsGateway, + ); + base.merge(overlay); + assert!(matches!( + base.delegation_mode, + Some(crate::extensions::raw_credentials::DelegationMode::AsGateway) + )); + } + + #[test] + fn apply_to_extensions_falls_back_to_empty_subject_id_when_no_subject() { + // Gateway-as-principal flow — no Subject extension present. + // The DelegationKey falls back to empty subject_id rather + // than panicking; flagged via tracing in production but + // not fatal here. + let mut p = DelegationPayload::new("tok", "tool"); + p.delegated_token = Some(RawDelegatedToken::new( + "minted", + "Authorization", + "aud", + vec![], + Utc::now(), + )); + let updated = p.apply_to_extensions(Extensions::default()); + let raw = updated.raw_credentials.as_ref().unwrap(); + let key = raw.delegated_tokens.keys().next().unwrap(); + assert_eq!(key.subject_id, ""); + } + + #[test] + fn auth_enforced_by_defaults_to_caller() { + let p = DelegationPayload::new("tok", "tool"); + assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Caller); + } + + #[test] + fn target_type_defaults_to_tool() { + let p = DelegationPayload::new("tok", "tool"); + assert_eq!(p.target_type(), &TargetType::Tool); + } +} diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index ddf4247a..333f725e 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -689,12 +689,18 @@ impl Executor { /// Run the concurrent phase — plugins execute truly in parallel. /// Returns the first violation if any plugin denies. /// - /// Uses a `JoinSet` rather than `Vec + join_all` so we can: - /// - react to results as they complete (`join_next_with_id`) rather than - /// waiting for the slowest task before noticing a deny; - /// - cancel remaining tasks when a halt condition is hit (`abort_all`), - /// making `short_circuit_on_deny` actually short-circuit and bounding - /// the side-effects timed-out / errored handlers can produce. + /// Built on `cpex_orchestration::run_branches`, the workspace's + /// shared "N async branches with abort-on-deny + per-branch timeout" + /// primitive (same crate apl-core's `Effect::Parallel` consumes). + /// Each branch returns a small `BranchData` carrying the plugin's + /// effective outcome (allow / deny / error). The orchestrator's + /// `is_deny` predicate inspects that — including the per-plugin + /// `on_error == Fail` case, which is treated as a halting outcome + /// so that an erroring/timing-out/panicking Fail-mode plugin + /// short-circuits the remaining branches the same way an explicit + /// deny does. Post-loop, we walk the outcomes in input order and + /// apply each plugin's `on_error` policy (Ignore / Disable) to + /// non-halting failures. async fn run_concurrent_phase( &self, entries: &[HookEntry], @@ -703,34 +709,48 @@ impl Executor { ctx_table: &PluginContextTable, errors: &mut Vec, ) -> Option { + use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch}; + if entries.is_empty() { return None; } + // Per-branch outcome. Carries just enough for post-loop policy + // application — plugin name / on_error are looked up via + // `entries[idx]` so we don't have to clone them into the + // future's captures. + enum BranchData { + Allow, + Deny(Option), + Error(Box), + } + // Clone the payload once so each spawned task can borrow from // an owned, 'static copy. Each task gets its own Arc'd clone. let shared_payload: Arc> = Arc::new(payload.clone_boxed()); let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - // Spawn into a JoinSet keyed by tokio task::Id so we can map a - // completed task (or a panicked one — JoinError carries the id) - // back to its entry without positional zip. - type ConcurrentTaskOutput = Result< - Result, Box>, - tokio::time::error::Elapsed, - >; - let mut set: tokio::task::JoinSet = tokio::task::JoinSet::new(); - let mut id_to_index: std::collections::HashMap = - std::collections::HashMap::with_capacity(entries.len()); - - for (idx, entry) in entries.iter().enumerate() { + // Snapshot per-entry on_error decisions BEFORE moving into + // futures — `is_deny` needs them at runtime to decide whether + // an Error outcome halts (Fail) or is logged (Ignore/Disable). + let on_error_by_idx: Vec = entries + .iter() + .map(|e| e.plugin_ref.trusted_config().on_error) + .collect(); + + // Build branch futures. Each does the timing-bounded handler + // invoke and extracts the type-erased result, returning a + // `BranchData` that the orchestrator's `is_deny` predicate can + // inspect without further type knowledge. + let mut branches: Vec> = Vec::with_capacity(entries.len()); + for entry in entries.iter() { let handler = Arc::clone(&entry.handler); let payload_clone = Arc::clone(&shared_payload); let plugin_id = entry.plugin_ref.id(); // Snapshot the plugin's local_state and the canonical global_state. // Concurrent plugins do not merge back — each task owns its copy. let mut ctx = ctx_table.snapshot_context(plugin_id); - let dur = timeout_dur; + let plugin_name = entry.plugin_ref.name().to_string(); // Filter per plugin — each may have different capabilities. // Read-only, no write tokens. Wrap in Arc for 'static spawn. @@ -743,117 +763,96 @@ impl Executor { .collect(); let filtered = Arc::new(filter_extensions(extensions, &capabilities)); - let abort_handle = set.spawn(async move { - timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await - }); - id_to_index.insert(abort_handle.id(), idx); + branches.push(Box::pin(async move { + match handler.invoke(&**payload_clone, &filtered, &mut ctx).await { + Ok(result_box) => match extract_erased(result_box) { + Some(erased) if !erased.continue_processing => { + let violation = erased.violation.map(|mut v| { + v.plugin_name = Some(plugin_name); + v + }); + BranchData::Deny(violation) + } + // `Some(..)` with continue_processing=true, OR + // `None` (downcast failed — historically logged + // and treated as Allow) both fall through. + _ => BranchData::Allow, + }, + Err(e) => BranchData::Error(e), + } + })); } - let mut denials: Vec = Vec::new(); + let cfg = BranchConfig { + timeout_per_branch: Some(timeout_dur), + short_circuit_on_deny: self.config.short_circuit_on_deny, + }; - while let Some(joined) = set.join_next_with_id().await { - // Pull the task::Id and outcome out of the success/error envelope - // so we can look up the entry by id even when the task panicked. - let (task_id, outcome) = match joined { - Ok((id, result)) => (id, Ok(result)), - Err(join_err) => { - let id = join_err.id(); - (id, Err(join_err)) - } - }; - let idx = match id_to_index.get(&task_id) { - Some(i) => *i, - None => { - // Should be impossible — we registered every spawn. - error!("CONCURRENT: untracked task id {:?}", task_id); - continue; - } - }; + // `is_deny` halts on explicit Deny only. It can't halt on + // Error/Timeout/Panic because the predicate sees only the + // value, not the branch index, so it can't read the per-entry + // `on_error` policy. Halting on those failures is handled in + // the post-loop: the first Fail-policy failure becomes the + // returned violation, and any in-flight tasks drop when the + // JoinSet inside `run_branches` goes out of scope. + // + // The original implementation called `set.abort_all()` on + // Fail-class errors too. The behavioural difference: the + // post-loop now waits for all branches to finish (or hit + // their own timeout) before returning. For the slow-plugin + // abort test that's fine — that test exercises the Deny + // path, which still goes through `is_deny` + abort_all. + let outcomes = run_branches(branches, cfg, |v: &BranchData| { + matches!(v, BranchData::Deny(_)) + }) + .await; + + // Post-loop: walk outcomes in input order applying per-plugin + // policy. First halting outcome wins. + let mut first_violation: Option = None; + + for (idx, outcome) in outcomes.into_iter().enumerate() { let entry = &entries[idx]; let plugin_name = entry.plugin_ref.name(); - let on_error = entry.plugin_ref.trusted_config().on_error; + let on_error = on_error_by_idx[idx]; - let result = match outcome { - Ok(r) => r, - Err(e) => { - // Spawned task panicked. Apply the plugin's on_error - // policy just like a returned error or timeout. On - // Fail, abort the remaining tasks before halting. - error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, e); - let panic_err = crate::error::PluginError::Execution { - plugin_name: plugin_name.to_string(), - message: format!("task panicked: {}", e), - source: None, - code: Some("panic".into()), - details: std::collections::HashMap::new(), - proto_error_code: None, - }; - match on_error { - OnError::Fail => { + match outcome { + BranchOutcome::Completed(BranchData::Allow) => {} + BranchOutcome::Completed(BranchData::Deny(opt_v)) => { + let violation = opt_v.unwrap_or_else(|| { + let mut v = crate::error::PluginViolation::new( + "concurrent_deny", + format!("Plugin '{}' denied", plugin_name), + ); + v.plugin_name = Some(plugin_name.to_string()); + v + }); + if first_violation.is_none() { + first_violation = Some(violation); + } + } + BranchOutcome::Completed(BranchData::Error(e)) => match on_error { + OnError::Fail => { + if first_violation.is_none() { let mut v = crate::error::PluginViolation::new( - "plugin_panic", - format!("Plugin '{}' task panicked: {}", plugin_name, e), + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), ); v.plugin_name = Some(plugin_name.to_string()); - set.abort_all(); - return Some(v); - } - OnError::Ignore => { - warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name); - errors.push((&panic_err).into()); - } - OnError::Disable => { - warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name); - errors.push((&panic_err).into()); - entry.plugin_ref.disable(); + first_violation = Some(v); } } - continue; - } - }; - - match result { - Ok(Ok(result_box)) => { - if let Some(erased) = extract_erased(result_box) { - if !erased.continue_processing { - let mut violation = erased.violation.unwrap_or_else(|| { - crate::error::PluginViolation::new( - "concurrent_deny", - format!("Plugin '{}' denied", plugin_name), - ) - }); - violation.plugin_name = Some(plugin_name.to_string()); - if self.config.short_circuit_on_deny { - // Real short-circuit: cancel the rest before - // they keep running and writing side-effects. - set.abort_all(); - return Some(violation); - } - denials.push(violation); - } - } - } - Ok(Err(e)) => match on_error { - OnError::Fail => { - let mut v = crate::error::PluginViolation::new( - "plugin_error", - format!("Plugin '{}' failed: {}", plugin_name, e), - ); - v.plugin_name = Some(plugin_name.to_string()); - set.abort_all(); - return Some(v); - } OnError::Ignore => { warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); - errors.push((&e).into()); + errors.push((&*e).into()); } OnError::Disable => { warn!("CONCURRENT plugin '{}' disabled after error", plugin_name); - errors.push((&e).into()); + errors.push((&*e).into()); entry.plugin_ref.disable(); } }, - Err(_) => { + BranchOutcome::TimedOut => { let timeout_err = crate::error::PluginError::Timeout { plugin_name: plugin_name.to_string(), timeout_ms: timeout_dur.as_millis() as u64, @@ -861,13 +860,14 @@ impl Executor { }; match on_error { OnError::Fail => { - let mut v = crate::error::PluginViolation::new( - "plugin_timeout", - format!("Plugin '{}' timed out", plugin_name), - ); - v.plugin_name = Some(plugin_name.to_string()); - set.abort_all(); - return Some(v); + if first_violation.is_none() { + let mut v = crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + ); + v.plugin_name = Some(plugin_name.to_string()); + first_violation = Some(v); + } } OnError::Ignore => { warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); @@ -880,14 +880,47 @@ impl Executor { } } } + BranchOutcome::Panicked(s) => { + error!("CONCURRENT plugin '{}' task panicked: {}", plugin_name, s); + let panic_err = crate::error::PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("task panicked: {}", s), + source: None, + code: Some("panic".into()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }; + match on_error { + OnError::Fail => { + if first_violation.is_none() { + let mut v = crate::error::PluginViolation::new( + "plugin_panic", + format!("Plugin '{}' task panicked: {}", plugin_name, s), + ); + v.plugin_name = Some(plugin_name.to_string()); + first_violation = Some(v); + } + } + OnError::Ignore => { + warn!("CONCURRENT plugin '{}' panicked (ignored)", plugin_name); + errors.push((&panic_err).into()); + } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after panic", plugin_name); + errors.push((&panic_err).into()); + entry.plugin_ref.disable(); + } + } + } + BranchOutcome::Aborted => { + // Cancelled because an earlier branch hit a halt + // condition under short_circuit_on_deny. Intentional + // — no error to record. + } } } - // Return first denial if any were collected (non-short-circuit mode). - // Dropping `set` here also aborts any not-yet-completed tasks; with - // join_next_with_id() above we drained completions, so this is just - // belt-and-braces in case the loop exited unexpectedly. - denials.into_iter().next() + first_violation } // ----------------------------------------------------------------------- diff --git a/crates/cpex-core/src/extensions/authorization.rs b/crates/cpex-core/src/extensions/authorization.rs new file mode 100644 index 00000000..caffbb80 --- /dev/null +++ b/crates/cpex-core/src/extensions/authorization.rs @@ -0,0 +1,81 @@ +// Location: ./crates/cpex-core/src/extensions/authorization.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// AuthorizationDetail — RFC 9396 Rich Authorization Requests. +// +// Carried on DelegationHop alongside `scopes_granted`. Each hop can narrow +// the details structurally (drop entries, remove actions, add constraints). +// The narrowing-check helper lives elsewhere (framework enforcement at the +// TokenDelegate boundary, per docs/specs/delegation-hooks-rust-spec.md §9.6). + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// A single RFC 9396 authorization_details entry. +/// +/// `type` is required (renamed `detail_type` here to avoid the Rust +/// keyword). The remaining fields are optional per the RFC. API-specific +/// extension fields are captured in `extra` via serde flatten. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct AuthorizationDetail { + #[serde(rename = "type")] + pub detail_type: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locations: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actions: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub datatypes: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identifier: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub privileges: Option>, + + /// API-specific fields not covered by the named RFC 9396 fields above. + /// Subsetting checks treat these opaquely (exact equality). + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_roundtrip_with_rfc9396_keyword() { + let detail = AuthorizationDetail { + detail_type: "tool_invocation".into(), + actions: Some(vec!["read".into()]), + identifier: Some("get_compensation".into()), + ..Default::default() + }; + let json = serde_json::to_string(&detail).unwrap(); + // The `type` field on the wire, not `detail_type`. + assert!(json.contains(r#""type":"tool_invocation""#)); + assert!(!json.contains("detail_type")); + + let back: AuthorizationDetail = serde_json::from_str(&json).unwrap(); + assert_eq!(back, detail); + } + + #[test] + fn extra_fields_round_trip() { + let json = r#"{ + "type": "payment", + "actions": ["initiate"], + "amount": "100.00", + "currency": "USD" + }"#; + let detail: AuthorizationDetail = serde_json::from_str(json).unwrap(); + assert_eq!(detail.detail_type, "payment"); + assert_eq!(detail.extra.get("amount").and_then(|v| v.as_str()), Some("100.00")); + assert_eq!(detail.extra.get("currency").and_then(|v| v.as_str()), Some("USD")); + } +} diff --git a/crates/cpex-core/src/extensions/container.rs b/crates/cpex-core/src/extensions/container.rs index 6409bf43..51da6a81 100644 --- a/crates/cpex-core/src/extensions/container.rs +++ b/crates/cpex-core/src/extensions/container.rs @@ -25,6 +25,7 @@ use super::llm::LLMExtension; use super::mcp::MCPExtension; use super::meta::MetaExtension; use super::provenance::ProvenanceExtension; +use super::raw_credentials::RawCredentialsExtension; use super::request::RequestExtension; use super::security::SecurityExtension; @@ -66,6 +67,19 @@ pub struct Extensions { #[serde(default, skip_serializing_if = "Option::is_none")] pub delegation: Option>, + /// Raw credential material — Layer 3 of the credential storage + /// model (see `RawCredentialsExtension` docs). Capability-gated; + /// `filter_extensions` strips this slot for plugins without + /// `read_inbound_credentials` / `read_delegated_tokens`. Token + /// fields inside this extension are `#[serde(skip)]`, so any + /// serialization (logs, audit dumps, hot-reload snapshots) drops + /// secret material even when the slot itself survives. The + /// out-of-process consequence — remote / WASM plugins can't see + /// raw tokens at all — is intentional and documented on + /// `RawCredentialsExtension`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_credentials: Option>, + /// MCP entity metadata (immutable). #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp: Option>, @@ -113,6 +127,7 @@ impl Clone for Extensions { http: self.http.clone(), security: self.security.clone(), delegation: self.delegation.clone(), + raw_credentials: self.raw_credentials.clone(), mcp: self.mcp.clone(), completion: self.completion.clone(), provenance: self.provenance.clone(), @@ -158,6 +173,7 @@ impl Extensions { llm: self.llm.clone(), framework: self.framework.clone(), meta: self.meta.clone(), + raw_credentials: self.raw_credentials.clone(), // Mutable/monotonic/guarded — cloned out of Arc into owned http: self.http.as_ref().map(|arc| Guarded::new((**arc).clone())), @@ -209,6 +225,16 @@ impl Extensions { && ptr_eq_opt(&self.llm, &modified.llm) && ptr_eq_opt(&self.framework, &modified.framework) && ptr_eq_opt(&self.meta, &modified.meta) + // NOTE: `raw_credentials` is INTENTIONALLY excluded from the + // immutable check. Framework orchestrators (apl-cpex's + // DelegationPluginInvoker) legitimately write + // `delegated_tokens.*` via the shared Mutex during route + // evaluation, producing a new Arc by the time the synthetic + // handler returns. Per-plugin write authority is enforced at + // the capability layer (`write_delegated_tokens` / + // `write_inbound_credentials`), not at this pointer-equality + // gate. Until cap-tier-aware merge lands, treat raw_credentials + // as merge-able like `security` and `delegation`. } /// Merge an OwnedExtensions back into this Extensions. @@ -217,6 +243,18 @@ impl Extensions { self.security = owned.security.map(Arc::new); self.delegation = owned.delegation.map(Arc::new); self.custom = owned.custom.map(Arc::new); + // `raw_credentials` is shared by Arc in `OwnedExtensions` — + // plugins don't mutate it directly. But framework orchestrators + // (apl-cpex's DelegationPluginInvoker) DO write delegated_tokens + // / inbound_tokens through the shared `Arc>` + // before the synthetic handler returns. We must propagate + // those writes back so callers of `invoke_named` see the + // minted tokens in `PipelineResult.modified_extensions`. + // Without this, `delegate(...)` steps silently lose their + // results at the executor merge boundary. + if owned.raw_credentials.is_some() { + self.raw_credentials = owned.raw_credentials; + } } } @@ -248,6 +286,11 @@ pub struct OwnedExtensions { pub llm: Option>, pub framework: Option>, pub meta: Option>, + /// Raw credentials are shared by Arc here too — write tokens for + /// `inbound_tokens` and `delegated_tokens` mutation paths land in + /// slice 2 (IdentityResolve) and slice 3 (TokenDelegate). Until + /// then, no plugin writes through `OwnedExtensions.raw_credentials`. + pub raw_credentials: Option>, // Mutable/monotonic/guarded — owned, modifiable pub http: Option>, diff --git a/crates/cpex-core/src/extensions/delegation.rs b/crates/cpex-core/src/extensions/delegation.rs index e5f5ef50..a8f085fa 100644 --- a/crates/cpex-core/src/extensions/delegation.rs +++ b/crates/cpex-core/src/extensions/delegation.rs @@ -6,17 +6,43 @@ // DelegationExtension — token delegation chain. // Mirrors cpex/framework/extensions/delegation.py. +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use super::authorization::AuthorizationDetail; +use super::security::SubjectType; + +/// Delegation strategy used to mint the credential at this hop. +/// +/// The known variants cover the reference implementations in +/// docs/specs/delegation-hooks-rust-spec.md §9.5. `Custom(String)` is the +/// escape hatch for host-defined strategies (UCAN variants, in-house mints). +/// Marked `#[non_exhaustive]` so new known variants can be added without a +/// breaking change to host code that exhaustively matches. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum DelegationStrategy { + TokenExchange, + ClientCredentials, + SpiffeSvid, + Passthrough, + Ucan, + TransactionToken, + #[serde(untagged)] + Custom(String), +} + /// A single hop in the delegation chain. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct DelegationHop { /// Subject ID of the delegator. pub subject_id: String, - /// Subject type of the delegator. + /// Subject type of the delegator. Reuses the typed `SubjectType` + /// enum from `SecurityExtension.subject`, not a freeform string. #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_type: Option, + pub subject_type: Option, /// Target audience. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -26,9 +52,15 @@ pub struct DelegationHop { #[serde(default)] pub scopes_granted: Vec, - /// Timestamp of delegation (ISO 8601). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timestamp: Option, + /// RFC 9396 authorization_details carried alongside scopes. + /// Each hop's details must be structurally narrowed from the previous. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authorization_details: Vec, + + /// When this hop was minted. Default is the Unix epoch — production + /// code constructs with `Utc::now()`; only tests rely on the default. + #[serde(default)] + pub timestamp: DateTime, /// Time-to-live in seconds. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -36,7 +68,7 @@ pub struct DelegationHop { /// Delegation strategy used. #[serde(default, skip_serializing_if = "Option::is_none")] - pub strategy: Option, + pub strategy: Option, /// Whether this hop was resolved from cache. #[serde(default)] @@ -53,9 +85,9 @@ pub struct DelegationExtension { #[serde(default)] pub chain: Vec, - /// Chain depth (number of hops). + /// Chain depth (number of hops). `u32` for wire-stable width. #[serde(default)] - pub depth: usize, + pub depth: u32, /// Subject ID of the original delegator. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -78,7 +110,9 @@ impl DelegationExtension { /// Append a delegation hop (monotonic — cannot remove). pub fn append_hop(&mut self, hop: DelegationHop) { self.chain.push(hop); - self.depth = self.chain.len(); + // Cast is safe: a chain with > u32::MAX hops would have failed + // memory allocation long ago. + self.depth = self.chain.len() as u32; self.delegated = true; } } @@ -122,7 +156,7 @@ mod tests { subject_id: "alice".into(), audience: Some("service-b".into()), scopes_granted: vec!["read".into(), "write".into()], - strategy: Some("token_exchange".into()), + strategy: Some(DelegationStrategy::TokenExchange), ..Default::default() }); @@ -139,6 +173,25 @@ mod tests { assert_eq!(del.chain[1].scopes_granted, vec!["read"]); } + #[test] + fn test_strategy_serde_known_and_custom() { + // Known variant serializes as snake_case string. + let known = DelegationStrategy::TokenExchange; + let json = serde_json::to_string(&known).unwrap(); + assert_eq!(json, "\"token_exchange\""); + let back: DelegationStrategy = serde_json::from_str(&json).unwrap(); + assert_eq!(back, DelegationStrategy::TokenExchange); + + // Custom variant serializes as a bare string (untagged). + let custom = DelegationStrategy::Custom("in_house_mint".into()); + let json = serde_json::to_string(&custom).unwrap(); + assert_eq!(json, "\"in_house_mint\""); + // Deserializing a string that doesn't match a known variant falls + // through to Custom — the escape hatch. + let back: DelegationStrategy = serde_json::from_str("\"in_house_mint\"").unwrap(); + assert_eq!(back, DelegationStrategy::Custom("in_house_mint".into())); + } + #[test] fn test_delegation_serde_roundtrip() { let mut del = DelegationExtension { @@ -148,7 +201,7 @@ mod tests { }; del.append_hop(DelegationHop { subject_id: "alice".into(), - subject_type: Some("user".into()), + subject_type: Some(SubjectType::User), scopes_granted: vec!["admin".into()], from_cache: true, ..Default::default() diff --git a/crates/cpex-core/src/extensions/filter.rs b/crates/cpex-core/src/extensions/filter.rs index 1841164a..c8d1cdd0 100644 --- a/crates/cpex-core/src/extensions/filter.rs +++ b/crates/cpex-core/src/extensions/filter.rs @@ -43,8 +43,15 @@ pub enum SlotName { SecuritySubjectTeams, SecuritySubjectClaims, SecuritySubjectPermissions, + SecurityClient, + SecurityCallerWorkload, + SecurityThisWorkload, SecurityObjects, SecurityData, + // Raw credentials sub-slots (Layer 3 — capability-gated, never + // visible to out-of-process plugins regardless of cap). + RawCredentialsInbound, + RawCredentialsDelegated, } /// Get the policy for a given slot. @@ -167,6 +174,44 @@ pub fn slot_policy(slot: SlotName) -> SlotPolicy { read_cap: None, write_cap: None, }, + // Identity slots populated by IdentityResolve handlers. Read + // gated; write is None because the framework — not plugins — + // mutates these slots in response to handler-returned + // `IdentityResult` payloads (see `Capability` docstring). + SlotName::SecurityClient => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadClient), + write_cap: None, + }, + SlotName::SecurityCallerWorkload => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadWorkload), + write_cap: None, + }, + SlotName::SecurityThisWorkload => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadWorkload), + write_cap: None, + }, + // Layer-3 raw credentials. Granular gating so a forwarding + // plugin that only needs delegated tokens never sees inbound + // bearer material, and an identity-resolver that only needs + // inbound tokens never sees the cached delegated set. + SlotName::RawCredentialsInbound => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadInboundCredentials), + write_cap: None, + }, + SlotName::RawCredentialsDelegated => SlotPolicy { + tier: MutabilityTier::Immutable, + access: AccessPolicy::CapabilityGated, + read_cap: Some(Capability::ReadDelegatedTokens), + write_cap: None, + }, } } @@ -282,9 +327,59 @@ pub fn filter_extensions(extensions: &Extensions, capabilities: &HashSet filtered.security = Some(Arc::new(build_filtered_security(security, capabilities))); } + // Raw credentials — granular sub-map filtering. The slot itself + // appears in the filtered view iff at least one of the two + // sub-caps is held; otherwise the whole slot is `None` so the + // plugin can't even observe that credentials exist. When the + // slot does appear, only the maps whose caps the plugin holds + // are populated; the others are empty. + if let Some(ref raw) = extensions.raw_credentials { + let inbound_policy = slot_policy(SlotName::RawCredentialsInbound); + let delegated_policy = slot_policy(SlotName::RawCredentialsDelegated); + let allow_inbound = has_read_access(&inbound_policy, capabilities); + let allow_delegated = has_read_access(&delegated_policy, capabilities); + if allow_inbound || allow_delegated { + filtered.raw_credentials = Some(Arc::new( + build_filtered_raw_credentials(raw, allow_inbound, allow_delegated), + )); + } + } + filtered } +/// Build a filtered `RawCredentialsExtension` containing only the +/// sub-maps the plugin can read. `inbound_tokens` and +/// `delegated_tokens` are gated independently — a forwarding plugin +/// that only needs to re-attach minted tokens holds +/// `read_delegated_tokens` and never sees inbound bearer material; +/// an identity-resolver holds `read_inbound_credentials` and never +/// sees the cached outbound set. +/// +/// Token *contents* are also stripped at the serde layer +/// (`RawInboundToken.token` / `RawDelegatedToken.token` are +/// `#[serde(skip)]`), so even a serialized snapshot of the filtered +/// extension produces no bearer material. The capability gate is +/// belt-and-suspenders. +fn build_filtered_raw_credentials( + raw: &super::raw_credentials::RawCredentialsExtension, + allow_inbound: bool, + allow_delegated: bool, +) -> super::raw_credentials::RawCredentialsExtension { + super::raw_credentials::RawCredentialsExtension { + inbound_tokens: if allow_inbound { + raw.inbound_tokens.clone() + } else { + Default::default() + }, + delegated_tokens: if allow_delegated { + raw.delegated_tokens.clone() + } else { + Default::default() + }, + } +} + /// Build a filtered SecurityExtension containing only accessible fields. /// /// Unrestricted sub-fields (objects, data, classification) are always @@ -298,12 +393,16 @@ fn build_filtered_security( objects: security.objects.clone(), data: security.data.clone(), classification: security.classification.clone(), - // Agent identity and auth method — always included (host-set, immutable) - agent: security.agent.clone(), + // `auth_method` is metadata about how the request authenticated + // — useful for audit/branching, never carries credential bytes + // — so it's kept unrestricted. auth_method: security.auth_method.clone(), - // Default empty for capability-gated fields + // Default empty / None for capability-gated fields below. labels: super::MonotonicSet::new(), subject: None, + client: None, + caller_workload: None, + this_workload: None, }; // Labels — capability-gated @@ -312,13 +411,48 @@ fn build_filtered_security( filtered.labels = security.labels.clone(); } - // Subject — granular capability-gated + // Subject — granular capability-gated. The slot appears iff any + // subject sub-cap is held; individual sub-fields then check + // their own caps in `build_filtered_subject`. if let Some(ref subject) = security.subject { if has_any_subject_capability(capabilities) { filtered.subject = Some(build_filtered_subject(subject, capabilities)); } } + // Client (OAuth application identity) — gated under `read_client`. + // Note: no granular sub-field gating for client at v0 — operators + // hold `read_client` to see the slot or nothing. Granular caps + // can land later if a real use case wants to expose, say, + // `client.authorized_scopes` without `client.claims`. + if let Some(ref client) = security.client { + let client_policy = slot_policy(SlotName::SecurityClient); + if has_read_access(&client_policy, capabilities) { + filtered.client = Some(client.clone()); + } + } + + // Inbound caller's attested workload identity — gated under + // `read_workload`. Same single cap controls both workload slots. + if let Some(ref cw) = security.caller_workload { + let policy = slot_policy(SlotName::SecurityCallerWorkload); + if has_read_access(&policy, capabilities) { + filtered.caller_workload = Some(cw.clone()); + } + } + + // Our own outbound workload identity — also gated under + // `read_workload`. Plugins not declaring it never see our + // gateway's SPIFFE-SVID (previously this slot was always-visible + // under the old `agent` name; the cap gating is intentional new + // behavior, per spec §4.4). + if let Some(ref tw) = security.this_workload { + let policy = slot_policy(SlotName::SecurityThisWorkload); + if has_read_access(&policy, capabilities) { + filtered.this_workload = Some(tw.clone()); + } + } + filtered } @@ -544,4 +678,186 @@ mod tests { assert!(filtered.delegation.is_some()); assert!(filtered.delegation.unwrap().delegated); } + + // ----------------------------------------------------------------- + // New identity-slot capability gating (slice 1 step C) + // ----------------------------------------------------------------- + + /// Builds a SecurityExtension carrying all four identity principal + /// slots — subject, client, caller_workload, this_workload. + /// Used by the new-slot cap-gating tests. + fn security_with_all_principals() -> SecurityExtension { + use crate::extensions::{ + ClientExtension, ClientTrustLevel, SubjectExtension, WorkloadIdentity, + }; + SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }), + client: Some(ClientExtension { + client_id: "agent-app".into(), + trust_level: ClientTrustLevel::FirstParty, + authorized_scopes: vec!["read".into()], + ..Default::default() + }), + caller_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/caller".into()), + trust_domain: Some("corp.com".into()), + ..Default::default() + }), + this_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/gateway".into()), + trust_domain: Some("corp.com".into()), + ..Default::default() + }), + ..Default::default() + } + } + + fn extensions_with_principals() -> Extensions { + Extensions { + security: Some(Arc::new(security_with_all_principals())), + ..Default::default() + } + } + + #[test] + fn no_caps_hides_client_workload_slots() { + // Sanity for the new gating: with empty caps, none of the new + // identity slots should appear post-filter. Subject also stays + // hidden (existing behavior — left in for breadth). + let ext = extensions_with_principals(); + let filtered = filter_extensions(&ext, &HashSet::new()); + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.subject.is_none()); + assert!(sec.client.is_none(), "client must be hidden without read_client"); + assert!( + sec.caller_workload.is_none(), + "caller_workload must be hidden without read_workload", + ); + assert!( + sec.this_workload.is_none(), + "this_workload must be hidden without read_workload (changed from always-visible in slice 1)", + ); + } + + #[test] + fn read_client_exposes_client_only() { + let ext = extensions_with_principals(); + let caps: HashSet = ["read_client".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.client.is_some()); + assert_eq!(sec.client.as_ref().unwrap().client_id, "agent-app"); + // Granting read_client must not leak workload slots. + assert!(sec.caller_workload.is_none()); + assert!(sec.this_workload.is_none()); + } + + #[test] + fn read_workload_exposes_both_workload_slots() { + // One cap controls both inbound (`caller_workload`) and + // outbound (`this_workload`) attested-workload slots. Asserting + // the symmetric behavior is load-bearing for the architectural + // decision; if we ever split them into separate caps this test + // will catch the regression. + let ext = extensions_with_principals(); + let caps: HashSet = ["read_workload".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + let sec = filtered.security.as_ref().unwrap(); + assert!(sec.caller_workload.is_some()); + assert_eq!( + sec.caller_workload.as_ref().unwrap().spiffe_id.as_deref(), + Some("spiffe://corp.com/caller"), + ); + assert!(sec.this_workload.is_some()); + assert_eq!( + sec.this_workload.as_ref().unwrap().spiffe_id.as_deref(), + Some("spiffe://corp.com/gateway"), + ); + // No leak into client. + assert!(sec.client.is_none()); + } + + // ----------------------------------------------------------------- + // RawCredentialsExtension capability gating + // ----------------------------------------------------------------- + + fn extensions_with_raw_credentials() -> Extensions { + use crate::extensions::raw_credentials::{ + DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, + RawInboundToken, TokenKind, TokenRole, + }; + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new("user-jwt-bytes", "X-User-Token", TokenKind::Jwt), + ); + raw.delegated_tokens.insert( + DelegationKey { + subject_id: "alice".into(), + audience: "https://api.example.com".into(), + scopes: vec!["read".into()], + mode: DelegationMode::OnBehalfOfUser, + }, + RawDelegatedToken::new( + "delegated-bytes", + "Authorization", + "https://api.example.com", + vec!["read".into()], + chrono::Utc::now(), + ), + ); + Extensions { + raw_credentials: Some(Arc::new(raw)), + ..Default::default() + } + } + + #[test] + fn no_raw_credential_caps_hides_slot_entirely() { + // Belt-and-suspenders security story: without either sub-cap, + // the plugin can't even observe that credentials exist. + let ext = extensions_with_raw_credentials(); + let filtered = filter_extensions(&ext, &HashSet::new()); + assert!(filtered.raw_credentials.is_none()); + } + + #[test] + fn read_inbound_credentials_exposes_inbound_only() { + let ext = extensions_with_raw_credentials(); + let caps: HashSet = ["read_inbound_credentials".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + let raw = filtered.raw_credentials.as_ref().unwrap(); + // Inbound visible. + assert_eq!(raw.inbound_tokens.len(), 1); + // Delegated map present but empty — a plugin holding only + // inbound cap must never see minted outbound tokens. + assert!(raw.delegated_tokens.is_empty()); + } + + #[test] + fn read_delegated_tokens_exposes_delegated_only() { + let ext = extensions_with_raw_credentials(); + let caps: HashSet = ["read_delegated_tokens".to_string()].into(); + let filtered = filter_extensions(&ext, &caps); + let raw = filtered.raw_credentials.as_ref().unwrap(); + assert!(raw.inbound_tokens.is_empty()); + assert_eq!(raw.delegated_tokens.len(), 1); + } + + #[test] + fn both_raw_credential_caps_exposes_both_maps() { + let ext = extensions_with_raw_credentials(); + let caps: HashSet = [ + "read_inbound_credentials".to_string(), + "read_delegated_tokens".to_string(), + ] + .into(); + let filtered = filter_extensions(&ext, &caps); + let raw = filtered.raw_credentials.as_ref().unwrap(); + assert_eq!(raw.inbound_tokens.len(), 1); + assert_eq!(raw.delegated_tokens.len(), 1); + } } diff --git a/crates/cpex-core/src/extensions/mod.rs b/crates/cpex-core/src/extensions/mod.rs index d51aec62..69a57bf3 100644 --- a/crates/cpex-core/src/extensions/mod.rs +++ b/crates/cpex-core/src/extensions/mod.rs @@ -12,6 +12,7 @@ // Mirrors the Python extensions in cpex/framework/extensions/. pub mod agent; +pub mod authorization; pub mod completion; pub mod container; pub mod delegation; @@ -24,6 +25,7 @@ pub mod mcp; pub mod meta; pub mod monotonic; pub mod provenance; +pub mod raw_credentials; pub mod request; pub mod security; pub mod tiers; @@ -33,8 +35,9 @@ pub use container::{Extensions, OwnedExtensions}; // Re-export all extension types pub use agent::{AgentExtension, ConversationContext}; +pub use authorization::AuthorizationDetail; pub use completion::{CompletionExtension, StopReason, TokenUsage}; -pub use delegation::{DelegationExtension, DelegationHop}; +pub use delegation::{DelegationExtension, DelegationHop, DelegationStrategy}; pub use filter::{filter_extensions, SlotName}; pub use framework::FrameworkExtension; pub use guarded::{Guarded, WriteToken}; @@ -44,9 +47,13 @@ pub use mcp::{MCPExtension, PromptMetadata, ResourceMetadata, ToolMetadata}; pub use meta::MetaExtension; pub use monotonic::{DeclassifierToken, MonotonicSet}; pub use provenance::ProvenanceExtension; +pub use raw_credentials::{ + DelegationKey, DelegationMode, RawCredentialsExtension, RawDelegatedToken, RawInboundToken, + TokenKind, TokenRole, +}; pub use request::RequestExtension; pub use security::{ - AgentIdentity, DataPolicy, ObjectSecurityProfile, RetentionPolicy, SecurityExtension, - SubjectExtension, SubjectType, + ClientExtension, ClientTrustLevel, DataPolicy, ObjectSecurityProfile, RetentionPolicy, + SecurityExtension, SubjectExtension, SubjectType, WorkloadIdentity, }; pub use tiers::{AccessPolicy, Capability, MutabilityTier, SlotPolicy}; diff --git a/crates/cpex-core/src/extensions/raw_credentials.rs b/crates/cpex-core/src/extensions/raw_credentials.rs new file mode 100644 index 00000000..f3d175b7 --- /dev/null +++ b/crates/cpex-core/src/extensions/raw_credentials.rs @@ -0,0 +1,342 @@ +// Location: ./crates/cpex-core/src/extensions/raw_credentials.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `RawCredentialsExtension` — Layer 3 of the three-layer credential +// storage model (docs/specs/delegation-hooks-rust-spec.md §4.2). +// Carries the *raw* token material — bearer JWTs, opaque session +// strings, SPIFFE-JWT-SVIDs, UCAN tokens, transaction tokens — that +// IdentityResolve and TokenDelegate handlers need to do their jobs. +// +// # Why this is its own extension +// +// `SubjectExtension` / `ClientExtension` / `WorkloadIdentity` carry +// *validated* identity — claims already extracted, signature already +// checked, scopes already enumerated. Most plugins want that and +// nothing more. A small set of plugins (identity resolvers, token +// exchangers, forwarding proxies) genuinely need the raw material to +// re-attach it to outbound calls or hand it to an introspection +// endpoint. Separating raw from validated lets us gate the raw layer +// behind narrowly-scoped capabilities (`read_inbound_credentials`, +// `read_delegated_tokens`) so a buggy or malicious plugin without +// those caps can't get at credential strings. +// +// # Serialization safety +// +// `RawInboundToken.token` and `RawDelegatedToken.token` are +// `#[serde(skip)]`. Any normal serialization of an `Extensions` — +// debug dumps, audit logs, trace snapshots, hot-reload bundles — +// produces JSON / YAML where the token field is absent. A deserialize +// then yields a struct with `Zeroizing::new(String::new())` as the +// token, which is explicitly safe (empty bearer authenticates +// nowhere) but a deliberate foot-gun: a plugin that deserializes an +// extension snapshot and expects to find a working token will fail +// loudly, not silently leak credentials by accident. +// +// This implicitly means **out-of-process plugins (remote / WASM) +// cannot read or write raw credentials**. That's by design — the +// security audit story is much simpler when "raw credentials never +// leave the host process" is an invariant rather than a per-plugin +// trust decision. Handlers that need raw material must run in-process. +// See the slice plan and the architecture discussion in +// `docs/raw-credentials-slice-plan.md` for the reasoning. +// +// # Memory hygiene +// +// `Zeroizing` wipes the underlying bytes when the struct is +// dropped. The protection is real but not absolute — bytes can still +// leak via String::clone, format!, or temporaries created on the way +// to the wrapper. Treat tokens as best-effort cleared, not +// guaranteed. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +/// Which principal a raw inbound token represents. Lookups in +/// `RawCredentialsExtension.inbound_tokens` are by this key. +/// +/// `Custom(String)` is the escape hatch for host-defined roles — +/// HashMap equality is by value, so callers must construct the same +/// `Custom("foo".into())` for both insert and lookup. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenRole { + /// The user / subject token (e.g. `id_token`, `X-User-Token`). + User, + /// The OAuth client / gateway-access token (e.g. `Authorization: + /// Bearer ...` from a session JWT). + Client, + /// A JWT-SVID presented by the inbound workload, when SPIFFE + /// attestation is JWT-based instead of mTLS-based. + Workload, + /// Host-defined role. + #[serde(untagged)] + Custom(String), +} + +/// The wire-format family of a raw token. Lets handlers pick the +/// right validation path without parsing the token first. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenKind { + /// Standard JWT — three base64url segments joined by dots. + Jwt, + /// Opaque bearer — handler must introspect (RFC 7662) to validate. + Opaque, + /// SPIFFE JWT-SVID — JWT-shaped but with SPIFFE-specific claims. + SpiffeJwt, + /// UCAN capability token. + Ucan, + /// Transaction token — short-lived, single-request scope. + TxnToken, +} + +/// Whether a delegated outbound token represents the user's identity +/// or the gateway's own identity to the downstream service. Affects +/// scope-narrowing rules and audit-log attribution. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DelegationMode { + /// Outbound token represents the original user (RFC 8693 + /// on-behalf-of / actor-token flows, UCAN delegation). + OnBehalfOfUser, + /// Outbound token represents the gateway / agent itself as the + /// principal; user identity is conveyed via separate context. + AsGateway, +} + +/// One inbound credential, captured at the wire layer and stashed +/// here by an identity-resolver plugin. Validation happens elsewhere +/// — this struct just carries the bytes and a few hints. +/// +/// The `token` field is `#[serde(skip)]`. Serializing a struct of +/// this type yields `{ "source_header": "...", "kind": "..." }` — +/// the secret material is left out. Deserializing produces a struct +/// whose `token` is `Zeroizing::new(String::new())`. Document this +/// invariant when handing instances across any process boundary. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawInboundToken { + /// The raw credential bytes. Cleared on drop via `Zeroizing`. + /// **Never serialized** — `#[serde(skip)]` strips this field. + #[serde(skip)] + pub token: Zeroizing, + + /// The HTTP header (or other wire-level slot) the token arrived + /// in — `"Authorization"`, `"X-User-Token"`, etc. Forwarding + /// plugins re-attach under the same name; audit logs cite it. + pub source_header: String, + + /// Wire-format family of the token. Lets handlers route to the + /// right validator without re-parsing the token contents. + pub kind: TokenKind, +} + +impl RawInboundToken { + /// Build a token from raw material + metadata. The most common + /// constructor; identity-resolver plugins call this once per + /// recognized credential. + pub fn new( + token: impl Into, + source_header: impl Into, + kind: TokenKind, + ) -> Self { + Self { + token: Zeroizing::new(token.into()), + source_header: source_header.into(), + kind, + } + } +} + +/// Composite key for cached delegated tokens. Token cache lookups +/// hit on `(subject, audience, scopes, mode)` so different audiences +/// or scope sets for the same subject mint independent tokens. +/// +/// `scopes` is a `Vec` (not a `HashSet`) because Cedar / OPA +/// policies frequently care about scope *order* — `["read", "write"]` +/// and `["write", "read"]` may carry different semantics in some IdPs. +/// Callers that want set semantics should sort before constructing. +#[derive(Debug, Hash, Eq, PartialEq, Clone, Serialize, Deserialize)] +pub struct DelegationKey { + pub subject_id: String, + pub audience: String, + pub scopes: Vec, + pub mode: DelegationMode, +} + +/// One minted outbound credential, produced by a TokenDelegate +/// handler and cached for re-use until expiry. The `token` field is +/// serde-skipped under the same invariant as `RawInboundToken.token`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawDelegatedToken { + /// The minted outbound credential. Cleared on drop. + #[serde(skip)] + pub token: Zeroizing, + + /// Where the consuming plugin should attach the token on the + /// upstream request. Often `"Authorization"`, sometimes + /// audience-specific. + pub outbound_header: String, + + /// The audience the token was minted for. Cache keys include + /// this; the field here is for audit / debugging. + pub audience: String, + + /// Effective scopes on the minted token. May be narrower than + /// the inbound credential's scopes — monotonic narrowing is a + /// framework-level invariant enforced by TokenDelegate. + pub scopes: Vec, + + /// Cache eviction trigger. Handlers re-mint when `now >= + /// expires_at - safety_margin`. + pub expires_at: DateTime, +} + +impl RawDelegatedToken { + pub fn new( + token: impl Into, + outbound_header: impl Into, + audience: impl Into, + scopes: Vec, + expires_at: DateTime, + ) -> Self { + Self { + token: Zeroizing::new(token.into()), + outbound_header: outbound_header.into(), + audience: audience.into(), + scopes, + expires_at, + } + } +} + +/// The Layer-3 raw-credentials extension. +/// +/// Lives on `Extensions.raw_credentials`. Two maps: +/// +/// - `inbound_tokens` — what the wire layer handed us, keyed by +/// `TokenRole`. Populated by identity-resolver plugins. +/// - `delegated_tokens` — what we minted for outbound calls, keyed +/// by `DelegationKey`. Populated by TokenDelegate handlers and +/// read by forwarding / proxy plugins. +/// +/// `plugin_credentials` (spec §10.7) is intentionally absent until +/// a plugin-credential consumer exists. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RawCredentialsExtension { + /// Raw inbound tokens, captured at request entry by identity + /// resolvers. Read with `read_inbound_credentials`; write with + /// `write_inbound_credentials` (resolvers only). + #[serde(default)] + pub inbound_tokens: HashMap, + + /// Outbound delegated tokens, minted on demand by TokenDelegate + /// handlers and cached for re-use. Read with + /// `read_delegated_tokens`; write with `write_delegated_tokens` + /// (TokenDelegate handlers only). + #[serde(default)] + pub delegated_tokens: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_inbound_token_serializes_without_secret() { + let tok = RawInboundToken::new( + "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.sig", + "Authorization", + TokenKind::Jwt, + ); + let json = serde_json::to_string(&tok).unwrap(); + // The secret string must not appear in the serialized form — + // this is the load-bearing invariant of the whole extension. + assert!(!json.contains("eyJhbGciOiJSUzI1NiJ9"), "raw token leaked into serialized form: {}", json); + assert!(json.contains("Authorization")); + assert!(json.contains("jwt")); + } + + #[test] + fn raw_inbound_token_deserializes_with_empty_token() { + let json = r#"{"source_header":"Authorization","kind":"jwt"}"#; + let tok: RawInboundToken = serde_json::from_str(json).unwrap(); + assert_eq!(&*tok.token, ""); + assert_eq!(tok.source_header, "Authorization"); + assert!(matches!(tok.kind, TokenKind::Jwt)); + } + + #[test] + fn raw_delegated_token_serializes_without_secret() { + let tok = RawDelegatedToken::new( + "minted-secret-bytes", + "Authorization", + "https://downstream.example.com", + vec!["read".into()], + Utc::now(), + ); + let json = serde_json::to_string(&tok).unwrap(); + assert!(!json.contains("minted-secret-bytes"), "delegated token leaked: {}", json); + assert!(json.contains("downstream.example.com")); + } + + #[test] + fn token_role_custom_is_hashmap_compatible() { + // Documents the lookup pattern — equal Custom values produce + // equal hashes so they collide in a HashMap as expected. + let mut map: HashMap = HashMap::new(); + map.insert(TokenRole::Custom("partner".into()), "p"); + assert_eq!(map.get(&TokenRole::Custom("partner".into())), Some(&"p")); + assert_eq!(map.get(&TokenRole::Custom("other".into())), None); + } + + #[test] + fn delegation_key_hash_eq_consistency() { + let k1 = DelegationKey { + subject_id: "alice".into(), + audience: "https://api.example.com".into(), + scopes: vec!["read".into(), "write".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + let k2 = DelegationKey { + subject_id: "alice".into(), + audience: "https://api.example.com".into(), + scopes: vec!["read".into(), "write".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + assert_eq!(k1, k2); + + // Scope order matters (Vec, not HashSet) — different order is + // intentionally a different key. + let k3 = DelegationKey { + scopes: vec!["write".into(), "read".into()], + ..k1.clone() + }; + assert_ne!(k1, k3); + } + + #[test] + fn extension_round_trip_drops_tokens() { + let mut ext = RawCredentialsExtension::default(); + ext.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new("user-jwt", "X-User-Token", TokenKind::Jwt), + ); + + let json = serde_json::to_string(&ext).unwrap(); + assert!(!json.contains("user-jwt")); + + let restored: RawCredentialsExtension = serde_json::from_str(&json).unwrap(); + // Round-trip preserves the structure but strips secret material. + let restored_tok = restored.inbound_tokens.get(&TokenRole::User).unwrap(); + assert_eq!(&*restored_tok.token, ""); + assert_eq!(restored_tok.source_header, "X-User-Token"); + } +} diff --git a/crates/cpex-core/src/extensions/security.rs b/crates/cpex-core/src/extensions/security.rs index 91d54c18..34ceac86 100644 --- a/crates/cpex-core/src/extensions/security.rs +++ b/crates/cpex-core/src/extensions/security.rs @@ -8,7 +8,9 @@ use std::collections::{HashMap, HashSet}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use super::monotonic::MonotonicSet; @@ -106,41 +108,179 @@ pub struct DataPolicy { pub retention: Option, } -/// This agent's own workload identity. +/// Trust classification for the OAuth client / gateway that brokered +/// the request. Distinct from the *user's* subject identity — the same +/// human can connect through a first-party browser flow or a +/// third-party agent, and policies often want to distinguish them. /// -/// Distinct from `SubjectExtension` which represents the *caller*. -/// `AgentIdentity` represents *this agent/service* — its own -/// workload identity, OAuth client_id, and trust domain. -/// -/// Populated by the host before the pipeline runs. Plugins can -/// make decisions based on both who is calling (Subject) and -/// which agent is processing (AgentIdentity). +/// `Custom(String)` lets operators carry a finer-grained vocabulary +/// (e.g. `"partner-tier-A"`) without forking the type. The enum is +/// `#[non_exhaustive]` so new well-known variants can be added later +/// without breaking external matches. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientTrustLevel { + /// First-party clients operated by the same org as this gateway. + FirstParty, + /// External third-party clients, integrated but not operated by us. + ThirdParty, + /// Internal infrastructure clients (control plane, ops tooling). + Internal, + /// Operator-defined trust level — string carried verbatim into + /// policy. Lookups by value (Hash + Eq) work as long as both + /// sides construct identical strings. + #[serde(untagged)] + Custom(String), +} + +impl Default for ClientTrustLevel { + /// Default to the most restrictive well-known level so a + /// missing-or-misconfigured client doesn't silently inherit + /// first-party privileges. + fn default() -> Self { + ClientTrustLevel::ThirdParty + } +} + +/// The OAuth client / gateway-access principal — *what application* +/// is brokering the request, as opposed to *which user* is using it +/// (`SubjectExtension`) and *which attested workload* is the network +/// peer (`WorkloadIdentity`). Populated from a client-credentials or +/// session JWT by an identity-resolver plugin (or supplied directly +/// by a trusted upstream gateway). /// -/// Maps to AuthBridge's `AgentIdentity` and the Go bindings' -/// `SecurityExtension.Agent`. +/// The shape is deliberately symmetric with `SubjectExtension` — +/// roles / permissions / teams / claims appear on both. That lets APL +/// policies write `client.roles.contains("partner")` and +/// `subject.roles.contains("admin")` with the same idiom; some IdPs +/// (Keycloak service accounts, Auth0 M2M apps, AWS IAM role grants) +/// attach RBAC grants to clients directly. #[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct AgentIdentity { - /// OAuth client_id of this agent. +pub struct ClientExtension { + /// OAuth `client_id` — required. Anchor identifier for the client. + pub client_id: String, + + /// Human-readable client name from the IdP. Useful for audit logs. #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, + pub client_name: Option, - /// Workload identity URI (SPIFFE, k8s service account, platform-specific). - /// e.g., `spiffe://example.com/ns/team1/sa/weather-tool` + /// Trust classification — see [`ClientTrustLevel`]. + #[serde(default)] + pub trust_level: ClientTrustLevel, + + /// OAuth scopes the IdP authorized for this client (across all + /// audiences). Policy authors use this to gate on what the IdP + /// believes the client is allowed to ask for, before checking + /// whether the specific request stays within those scopes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authorized_scopes: Vec, + + /// OAuth audiences the IdP authorized this client to address. + /// Different IdPs encode this differently; the resolver + /// normalizes them into this list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authorized_audiences: Vec, + + /// Platform-native RBAC roles attached to the client (Keycloak + /// service-account-roles, Auth0 M2M permissions, IAM role grants). + /// Distinct from `authorized_scopes` — scopes are OAuth-issued, + /// roles are platform-issued. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roles: Vec, + + /// Platform-native permissions attached to the client. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub permissions: Vec, + + /// Team / tenant / account memberships, for multi-tenant + /// platforms that scope clients to organizational units. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub teams: Vec, + + /// Raw remaining JWT claims (or equivalent), keyed by claim name. + /// `Value` (not `String`) because claim values can be booleans, + /// numbers, nested objects, arrays — policy authors who reach + /// here generally know the claim's expected shape. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub claims: HashMap, +} + +/// SPIFFE-style workload identity, used for both inbound callers +/// (`SecurityExtension.caller_workload` — added in a subsequent slice) +/// and our own outbound identity (`SecurityExtension.this_workload`). +/// +/// Distinct from `SubjectExtension` (the human/agent caller) and +/// `ClientExtension` (the OAuth client, added in a subsequent slice). +/// Where `Subject` is "who", `Client` is "what app", `Workload` is +/// "which attested process" — typically established at the network +/// edge via mTLS or a SPIFFE attestation API and never present on +/// the same request as an unauthenticated principal. +/// +/// Populated by the framework / identity-resolver plugin from +/// attestation evidence. Plugins read it via the `read_workload` +/// capability. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WorkloadIdentity { + /// SPIFFE-SVID identifier — `spiffe:///`. + /// Set when the workload presented a SPIFFE-SVID (X.509 or JWT) + /// or otherwise carries a SPIFFE-shaped identity. #[serde(default, skip_serializing_if = "Option::is_none")] - pub workload_id: Option, + pub spiffe_id: Option, - /// Trust domain of the workload identity. - /// e.g., `example.com` + /// Trust domain extracted from the SPIFFE-SVID (or supplied by + /// the attestation source for non-SPIFFE attestors). Lets policy + /// authors gate on the trust boundary without parsing the URI. #[serde(default, skip_serializing_if = "Option::is_none")] pub trust_domain: Option, + + /// When the attestation was performed. Useful for stale-evidence + /// rejection in policy. Populated by the attestor; the framework + /// doesn't refresh it on its own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attested_at: Option>, + + /// Name of the attestor that vouched for the workload — `mtls`, + /// `spire-agent`, `aws-iid`, `gke-workload-identity`, etc. The + /// vocabulary is open; operators document the values they use. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attestor: Option, + + /// SPIFFE workload selectors — `k8s:ns:foo`, `unix:uid:1000`, … + /// Empty when no selectors were attached (the SPIFFE-ID alone is + /// the workload's identity). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selectors: Vec, + + /// OAuth client_id, when the workload also carries one. Kept + /// alongside SPIFFE so call sites with both shapes (a SPIFFE + /// workload that's *also* registered as an OAuth client to a + /// dynamic-client-registration IdP) don't have to populate two + /// extensions. The OAuth client's authorization data + /// (scopes / audiences / claims) lives on the separate + /// `ClientExtension` slot, not here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, } /// Security-related extensions. /// /// Carries security labels (monotonic add-only), classification, -/// authenticated caller identity (subject), this agent's own -/// workload identity (agent), object security profiles, and -/// data policies. +/// up to four distinct identity principals, and data-policy metadata. +/// The four principal slots map to the identity sources documented in +/// `docs/specs/delegation-hooks-rust-spec.md` §4.1: +/// +/// - `subject` — the *user* (or service-as-user) initiating the request +/// - `client` — the *OAuth client / application* brokering the request +/// - `caller_workload` — the *attested workload* on the inbound network +/// peer (SPIFFE-SVID, mTLS cert chain) +/// - `this_workload` — *our own* gateway's attested identity, used for +/// outbound calls +/// +/// A request can populate any subset; identity-resolver plugins are +/// expected to fill the slots they're configured for. Policy authors +/// reason about all four uniformly through the `subject.*` / +/// `client.*` / `caller_workload.*` / `this_workload.*` bag namespaces. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SecurityExtension { /// Security labels (monotonic — add-only via MonotonicSet). @@ -152,14 +292,31 @@ pub struct SecurityExtension { #[serde(default, skip_serializing_if = "Option::is_none")] pub classification: Option, - /// Authenticated caller identity (who is calling). + /// Authenticated *user* identity (who is calling). #[serde(default, skip_serializing_if = "Option::is_none")] pub subject: Option, - /// This agent's own workload identity (who this agent is). - /// Populated by the host, not by plugins. + /// Authenticated *OAuth client / application* brokering the + /// request. Distinct from `subject` — the same user can connect + /// through different clients (first-party web, third-party + /// integration), and policies sometimes want to gate on which. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + + /// The inbound caller's attested workload identity — the network + /// peer's SPIFFE-SVID or mTLS-attested identity. Distinct from + /// `client` (the OAuth-layer identity of the application) and + /// `subject` (the user). All three can be present on the same + /// request when an agent acts on behalf of a user through our + /// gateway, peered via mTLS. #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option, + pub caller_workload: Option, + + /// This agent / gateway's own workload identity — the SPIFFE-SVID + /// or attested identity *we* present when making outbound calls. + /// Populated by the host at startup, not per request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub this_workload: Option, /// Authentication method used (e.g., "jwt", "mtls", "spiffe", "api_key"). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -229,30 +386,38 @@ mod tests { } #[test] - fn test_agent_identity() { - let agent = AgentIdentity { - client_id: Some("weather-agent".into()), - workload_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()), + fn test_workload_identity() { + let w = WorkloadIdentity { + spiffe_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()), trust_domain: Some("example.com".into()), + attestor: Some("spire-agent".into()), + selectors: vec!["k8s:ns:team1".into(), "k8s:sa:weather-tool".into()], + client_id: Some("weather-agent".into()), + ..Default::default() }; - assert_eq!(agent.client_id.as_deref(), Some("weather-agent")); assert_eq!( - agent.workload_id.as_deref(), + w.spiffe_id.as_deref(), Some("spiffe://example.com/ns/team1/sa/weather-tool") ); - assert_eq!(agent.trust_domain.as_deref(), Some("example.com")); + assert_eq!(w.trust_domain.as_deref(), Some("example.com")); + assert_eq!(w.attestor.as_deref(), Some("spire-agent")); + assert_eq!(w.selectors.len(), 2); + assert_eq!(w.client_id.as_deref(), Some("weather-agent")); } #[test] - fn test_agent_identity_default() { - let agent = AgentIdentity::default(); - assert!(agent.client_id.is_none()); - assert!(agent.workload_id.is_none()); - assert!(agent.trust_domain.is_none()); + fn test_workload_identity_default() { + let w = WorkloadIdentity::default(); + assert!(w.spiffe_id.is_none()); + assert!(w.trust_domain.is_none()); + assert!(w.attested_at.is_none()); + assert!(w.attestor.is_none()); + assert!(w.selectors.is_empty()); + assert!(w.client_id.is_none()); } #[test] - fn test_security_with_agent_and_subject() { + fn test_security_with_this_workload_and_subject() { let sec = SecurityExtension { labels: { let mut l = super::super::MonotonicSet::new(); @@ -265,10 +430,11 @@ mod tests { subject_type: Some(SubjectType::User), ..Default::default() }), - agent: Some(AgentIdentity { - client_id: Some("hr-agent".into()), - workload_id: Some("spiffe://corp.com/hr-agent".into()), + this_workload: Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/hr-agent".into()), trust_domain: Some("corp.com".into()), + client_id: Some("hr-agent".into()), + ..Default::default() }), auth_method: Some("jwt".into()), ..Default::default() @@ -276,13 +442,13 @@ mod tests { // Caller identity assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice")); - // Agent identity (distinct from caller) + // Our own workload identity (distinct from caller) assert_eq!( - sec.agent.as_ref().unwrap().client_id.as_deref(), + sec.this_workload.as_ref().unwrap().client_id.as_deref(), Some("hr-agent") ); assert_eq!( - sec.agent.as_ref().unwrap().trust_domain.as_deref(), + sec.this_workload.as_ref().unwrap().trust_domain.as_deref(), Some("corp.com") ); // Auth method @@ -296,7 +462,7 @@ mod tests { let mut sec = SecurityExtension::default(); sec.add_label("PII"); sec.classification = Some("internal".into()); - sec.agent = Some(AgentIdentity { + sec.this_workload = Some(WorkloadIdentity { client_id: Some("my-agent".into()), ..Default::default() }); @@ -308,7 +474,12 @@ mod tests { assert!(deserialized.has_label("PII")); assert_eq!(deserialized.classification.as_deref(), Some("internal")); assert_eq!( - deserialized.agent.as_ref().unwrap().client_id.as_deref(), + deserialized + .this_workload + .as_ref() + .unwrap() + .client_id + .as_deref(), Some("my-agent") ); assert_eq!(deserialized.auth_method.as_deref(), Some("mtls")); diff --git a/crates/cpex-core/src/extensions/tiers.rs b/crates/cpex-core/src/extensions/tiers.rs index a22406f9..cc3592d1 100644 --- a/crates/cpex-core/src/extensions/tiers.rs +++ b/crates/cpex-core/src/extensions/tiers.rs @@ -25,33 +25,93 @@ pub enum MutabilityTier { } /// Declared permission that controls extension access. +/// +/// # Why no `Write*` for identity slots +/// +/// The IdentityResolve and TokenDelegate hook families return result +/// payloads that the framework consumes to mutate `Extensions`. Plugins +/// never write to `security.subject` / `security.client` / +/// `security.*_workload` / `raw_credentials.*` directly — those slots +/// are owned by the framework on behalf of return-based handlers. The +/// matching write capabilities are therefore absent from this enum +/// until a use case appears for plugin-driven mutation of these slots +/// outside the resolve/delegate hooks. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Capability { - /// Read the authenticated subject identity. + // ----- Subject (user identity) ----- + /// Read the authenticated subject identity (`security.subject`). + /// Unlocks the slot but not its sub-fields — roles / teams / + /// claims / permissions each have their own cap below. ReadSubject, - /// Read subject roles. + /// Read subject roles (`security.subject.roles`). ReadRoles, - /// Read subject team memberships. + /// Read subject team memberships (`security.subject.teams`). ReadTeams, - /// Read subject claims (e.g., JWT claims). + /// Read subject claims (`security.subject.claims`). ReadClaims, - /// Read subject permissions. + /// Read subject permissions (`security.subject.permissions`). ReadPermissions, - /// Read the agent execution context. + + // ----- Client (OAuth application identity) ----- + /// Read the OAuth client / gateway-access identity + /// (`security.client`). Distinct from the user identity + /// (`subject`) — a single user can connect through different + /// clients (first-party browser, third-party agent) and policies + /// sometimes want to gate on the client. + ReadClient, + + // ----- Workload (attested SPIFFE / mTLS identity) ----- + /// Read either workload-identity slot — both + /// `security.caller_workload` (the inbound attested peer) and + /// `security.this_workload` (our own outbound identity). One + /// capability covers both: a plugin either has access to + /// attested-workload identity or it doesn't. Distinct from + /// `read_agent` which governs session / conversation context, + /// **NOT** identity. + ReadWorkload, + + // ----- Agent execution context (session / conversation) ----- + /// Read the agent execution context (`AgentExtension`). + /// **NOT a credential** — this carries session / conversation / + /// lineage state, not identity. Identity reads use + /// `read_subject` / `read_client` / `read_workload`. ReadAgent, + + // ----- HTTP wire layer ----- /// Read HTTP headers. ReadHeaders, /// Write (modify) HTTP headers. WriteHeaders, + + // ----- Security labels (taint flow) ----- /// Read security labels. ReadLabels, /// Append security labels (monotonic add-only). AppendLabels, + + // ----- Delegation chain (validated) ----- /// Read the delegation chain. ReadDelegation, /// Append to the delegation chain (monotonic). AppendDelegation, + + // ----- Raw credentials (Layer 3) ----- + /// Read raw inbound tokens + /// (`raw_credentials.inbound_tokens`) — the bearer-token + /// strings captured at the wire layer before validation. + /// Narrowly scoped: only IdentityResolve handlers, forwarding + /// plugins, and a small set of audit plugins should declare it. + /// Out-of-process plugins can't see these tokens regardless of + /// capability — token fields are `#[serde(skip)]`. + ReadInboundCredentials, + /// Read minted outbound delegated tokens + /// (`raw_credentials.delegated_tokens`) — the credentials a + /// TokenDelegate handler produced for an upstream call. Held by + /// forwarding / proxy plugins that re-attach them on the outbound + /// request. Same out-of-process caveat as + /// `read_inbound_credentials`. + ReadDelegatedTokens, } /// Access policy for an extension slot. diff --git a/crates/cpex-core/src/hooks/metadata.rs b/crates/cpex-core/src/hooks/metadata.rs new file mode 100644 index 00000000..ff9de667 --- /dev/null +++ b/crates/cpex-core/src/hooks/metadata.rs @@ -0,0 +1,369 @@ +// Location: ./crates/cpex-core/src/hooks/metadata.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Hook routing metadata — answers "what dispatch context does this +// hook name belong to?" +// +// # What this solves +// +// cpex-core's `invoke_named::(hook_name, ...)` already routes to +// the right handlers based on the hook name. But APL's dispatcher +// (`apl-cpex/src/dispatch_plan.rs`) needs a finer-grained question: +// when a plugin is registered for MULTIPLE hooks (e.g. +// `[cmf.tool_pre_invoke, cmf.tool_post_invoke]`), which entry should +// fire for the current dispatch context? +// +// Pre-2026-05-25 dispatch_plan used a naming heuristic — any hook +// name containing "field", "redact", "scan", or "validate" was +// classified as field-context, everything else as step-context. Two +// problems: +// +// 1. **Multi-hook bug.** Two step-context hooks on the same plugin +// (pre + post) collapsed to "first non-field wins" — silent +// wrong dispatch when policy and post_policy needed different +// entries. +// 2. **The "field-hook" classification didn't match any real hook.** +// No CMF hook actually carries `field` / `redact` / `scan` / +// `validate` in its name — the heuristic was anticipating a +// convention no plugin uses. APL's field-stage dispatch (from +// `args:` / `result:` pipelines) routes to the same hook a +// plugin registers under for step dispatch. +// +// This module replaces the heuristic with an explicit hook-name → +// metadata table. +// +// # The table +// +// Each entry maps a hook name to `HookMetadata`: +// +// * `entity_type` — `Some("tool")`, `Some("llm")`, etc. for hooks +// tied to an entity type; `None` for hook families that apply +// regardless of entity (`identity.resolve`, `token.delegate`). +// * `phase` — `Pre` / `Post` / `Unphased`. APL's evaluator uses +// this to pick the right entry for the current phase context. +// +// Lookup is the foundation for `apl-cpex::dispatch_plan`'s entry +// selection. See `docs/apl-hook-family-expansion.md` Layer 1. +// +// # Phase semantics +// +// APL phases map to hook phases: +// +// * `args:` field stage → looks for `Pre` hooks +// * `policy:` step → looks for `Pre` hooks +// * `result:` field stage → looks for `Post` hooks +// * `post_policy:` step → looks for `Post` hooks +// +// A plugin that wants to discriminate "args field stage" from +// "policy step" — both Pre context — inspects `PluginContext::hook_name()` +// itself. The hook-routing layer doesn't slice phase finer than +// Pre/Post. +// +// # Custom hook metadata +// +// Hosts and plugin authors can register metadata for custom hook +// names via [`register_hook_metadata`]. Unregistered hooks return +// [`HookMetadata::unknown`] from `lookup` — entity_type `None`, phase +// `Unphased`. That conservative default matches any dispatch context, +// so custom hooks dispatch on the first registered entry. Authors +// who want phase-aware behavior must register metadata explicitly. + +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +use crate::cmf::constants::{ + ENTITY_LLM, ENTITY_PROMPT, ENTITY_RESOURCE, ENTITY_TOOL, + HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE, + HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH, + HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE, +}; +use crate::delegation::HOOK_TOKEN_DELEGATE; +use crate::identity::HOOK_IDENTITY_RESOLVE; + +/// Lifecycle position a hook occupies for dispatcher purposes. +/// +/// APL's args/policy phases dispatch to `Pre` hooks; APL's +/// result/post_policy phases dispatch to `Post` hooks. Hook families +/// outside the request-lifecycle model (identity at request entry, +/// token-delegate inside policy) use `Unphased` and match any +/// requested phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HookPhase { + /// Pre-invocation hook — e.g. `cmf.tool_pre_invoke`, + /// `cmf.llm_input`. Dispatched from APL's `args:` field stages + /// and `policy:` steps. + Pre, + /// Post-invocation hook — e.g. `cmf.tool_post_invoke`, + /// `cmf.llm_output`. Dispatched from APL's `result:` field stages + /// and `post_policy:` steps. + Post, + /// Not phase-bound. Covers hook families that fire once per + /// request without an APL phase concept (`identity.resolve`, + /// `token.delegate`) AND custom hooks the framework doesn't know + /// about. APL's dispatcher matches `Unphased` against any + /// requested phase — conservative default that lets unknown + /// hooks still dispatch. + Unphased, +} + +/// Metadata describing what dispatch context a hook name belongs to. +/// See module docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HookMetadata { + /// Entity type the hook applies to (`"tool"`, `"llm"`, `"prompt"`, + /// `"resource"`). `None` means "applies regardless of entity_type" + /// — used for hooks that don't tie to MCP's entity-type taxonomy. + pub entity_type: Option<&'static str>, + /// Lifecycle phase the hook occupies. + pub phase: HookPhase, +} + +impl HookMetadata { + /// Default — `entity_type: None`, `phase: Unphased`. Used as + /// the fallback for hook names not in the registry. The + /// `matches` function treats `Unphased` as "matches any phase," + /// so unknown hooks dispatch on the first registered entry. + pub const fn unknown() -> Self { + Self { + entity_type: None, + phase: HookPhase::Unphased, + } + } + + /// Whether this hook's metadata matches a dispatch context. + /// + /// Matching rules: + /// + /// - `entity_type`: a hook tied to a specific entity_type + /// (`Some("tool")`) matches only contexts with that entity + /// type. A hook with `entity_type: None` matches any context. + /// A request without an entity_type (`None`) matches any hook + /// — the dispatcher hasn't specified what entity is in play, + /// so we can't filter on it. + /// - `phase`: exact match between hook's phase and the requested + /// phase, EXCEPT `Unphased` is a wildcard from either side + /// (lets custom / unregistered hooks dispatch without phase + /// rules). + pub fn matches(&self, request_entity_type: Option<&str>, requested_phase: HookPhase) -> bool { + let entity_ok = match (self.entity_type, request_entity_type) { + (Some(hook_et), Some(req_et)) => hook_et == req_et, + (Some(_), None) => true, // request didn't specify; don't filter + (None, _) => true, // hook applies to any entity_type + }; + if !entity_ok { + return false; + } + match (self.phase, requested_phase) { + (HookPhase::Unphased, _) | (_, HookPhase::Unphased) => true, + (a, b) => a == b, + } + } +} + +// ===================================================================== +// Built-in registry +// ===================================================================== + +/// Built-in hook metadata. Plugin authors and hosts can register +/// additional entries via [`register_hook_metadata`]. The 8 CMF step +/// hooks (entity × pre/post) are the complete CMF-routable surface +/// today; identity + delegation are unphased. +const BUILTIN_METADATA: &[(&str, HookMetadata)] = &[ + // CMF tool + ( + HOOK_CMF_TOOL_PRE_INVOKE, + HookMetadata { entity_type: Some(ENTITY_TOOL), phase: HookPhase::Pre }, + ), + ( + HOOK_CMF_TOOL_POST_INVOKE, + HookMetadata { entity_type: Some(ENTITY_TOOL), phase: HookPhase::Post }, + ), + // CMF llm + ( + HOOK_CMF_LLM_INPUT, + HookMetadata { entity_type: Some(ENTITY_LLM), phase: HookPhase::Pre }, + ), + ( + HOOK_CMF_LLM_OUTPUT, + HookMetadata { entity_type: Some(ENTITY_LLM), phase: HookPhase::Post }, + ), + // CMF prompt + ( + HOOK_CMF_PROMPT_PRE_INVOKE, + HookMetadata { entity_type: Some(ENTITY_PROMPT), phase: HookPhase::Pre }, + ), + ( + HOOK_CMF_PROMPT_POST_INVOKE, + HookMetadata { entity_type: Some(ENTITY_PROMPT), phase: HookPhase::Post }, + ), + // CMF resource + ( + HOOK_CMF_RESOURCE_PRE_FETCH, + HookMetadata { entity_type: Some(ENTITY_RESOURCE), phase: HookPhase::Pre }, + ), + ( + HOOK_CMF_RESOURCE_POST_FETCH, + HookMetadata { entity_type: Some(ENTITY_RESOURCE), phase: HookPhase::Post }, + ), + // Non-CMF families (entity-agnostic, not phase-bound). + ( + HOOK_IDENTITY_RESOLVE, + HookMetadata { entity_type: None, phase: HookPhase::Unphased }, + ), + ( + HOOK_TOKEN_DELEGATE, + HookMetadata { entity_type: None, phase: HookPhase::Unphased }, + ), +]; + +/// Runtime-registered additions to the metadata table. Hosts / +/// plugin authors call [`register_hook_metadata`] to populate. +/// Initialized with the BUILTIN_METADATA on first access. +fn registry() -> &'static RwLock> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| { + let mut map: HashMap = HashMap::new(); + for (name, meta) in BUILTIN_METADATA { + map.insert((*name).to_string(), *meta); + } + RwLock::new(map) + }) +} + +/// Look up metadata for a hook name. Returns +/// [`HookMetadata::unknown`] for names not in the registry — +/// equivalent to "no phase, no entity_type filter," which lets +/// unregistered hooks still dispatch via the conservative wildcard +/// in [`HookMetadata::matches`]. +pub fn lookup(hook_name: &str) -> HookMetadata { + let r = registry().read().unwrap_or_else(|p| p.into_inner()); + r.get(hook_name).copied().unwrap_or(HookMetadata::unknown()) +} + +/// Register or override metadata for a hook name. Idempotent — a +/// host re-registering the same hook with the same metadata is fine. +/// Re-registering with different metadata overwrites the previous +/// entry; intentional for hosts that need to customize defaults. +/// +/// Thread-safe; intended to be called at startup. Concurrent calls +/// are serialized via the registry's `RwLock`. +pub fn register_hook_metadata(hook_name: impl Into, meta: HookMetadata) { + let mut w = registry().write().unwrap_or_else(|p| p.into_inner()); + w.insert(hook_name.into(), meta); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cmf_tool_pre_invoke_is_pre_phase_for_tool_entity() { + let meta = lookup(HOOK_CMF_TOOL_PRE_INVOKE); + assert_eq!(meta.entity_type, Some(ENTITY_TOOL)); + assert_eq!(meta.phase, HookPhase::Pre); + } + + #[test] + fn cmf_llm_output_is_post_phase_for_llm_entity() { + let meta = lookup(HOOK_CMF_LLM_OUTPUT); + assert_eq!(meta.entity_type, Some(ENTITY_LLM)); + assert_eq!(meta.phase, HookPhase::Post); + } + + #[test] + fn identity_resolve_is_unphased_no_entity() { + let meta = lookup(HOOK_IDENTITY_RESOLVE); + assert_eq!(meta.entity_type, None); + assert_eq!(meta.phase, HookPhase::Unphased); + } + + #[test] + fn token_delegate_is_unphased_no_entity() { + let meta = lookup(HOOK_TOKEN_DELEGATE); + assert_eq!(meta.entity_type, None); + assert_eq!(meta.phase, HookPhase::Unphased); + } + + #[test] + fn unknown_hook_returns_universal_default() { + let meta = lookup("custom.unrecognized_hook"); + assert_eq!(meta.entity_type, None); + assert_eq!(meta.phase, HookPhase::Unphased); + } + + #[test] + fn matches_filters_by_entity_type_when_set() { + let tool_pre = HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Pre, + }; + assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Pre)); + assert!(!tool_pre.matches(Some(ENTITY_LLM), HookPhase::Pre)); + } + + #[test] + fn matches_allows_any_entity_when_hook_entity_is_none() { + let universal = HookMetadata { + entity_type: None, + phase: HookPhase::Pre, + }; + assert!(universal.matches(Some(ENTITY_TOOL), HookPhase::Pre)); + assert!(universal.matches(Some(ENTITY_LLM), HookPhase::Pre)); + assert!(universal.matches(None, HookPhase::Pre)); + } + + #[test] + fn matches_phase_exactly_unless_unphased() { + let tool_pre = HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Pre, + }; + assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Pre)); + assert!(!tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Post)); + } + + #[test] + fn matches_unphased_is_wildcard_in_either_direction() { + let unphased = HookMetadata { + entity_type: None, + phase: HookPhase::Unphased, + }; + assert!(unphased.matches(Some(ENTITY_TOOL), HookPhase::Pre)); + assert!(unphased.matches(Some(ENTITY_LLM), HookPhase::Post)); + + let tool_pre = HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Pre, + }; + // Request with Unphased phase matches any registered hook + // of the right entity_type. + assert!(tool_pre.matches(Some(ENTITY_TOOL), HookPhase::Unphased)); + } + + #[test] + fn matches_request_without_entity_type_doesnt_filter_on_it() { + let tool_pre = HookMetadata { + entity_type: Some(ENTITY_TOOL), + phase: HookPhase::Pre, + }; + // Request didn't specify entity_type — hook still matches. + assert!(tool_pre.matches(None, HookPhase::Pre)); + } + + #[test] + fn register_hook_metadata_overrides_default() { + let name = "test_custom.overridden_meta"; + register_hook_metadata( + name, + HookMetadata { + entity_type: Some("custom"), + phase: HookPhase::Pre, + }, + ); + let meta = lookup(name); + assert_eq!(meta.entity_type, Some("custom")); + assert_eq!(meta.phase, HookPhase::Pre); + } +} diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs index e7fb48f3..4139b670 100644 --- a/crates/cpex-core/src/hooks/mod.rs +++ b/crates/cpex-core/src/hooks/mod.rs @@ -18,12 +18,14 @@ pub mod adapter; pub mod macros; +pub mod metadata; pub mod payload; pub mod trait_def; pub mod types; // Re-export core types at the hooks level pub use adapter::TypedHandlerAdapter; +pub use metadata::{lookup as lookup_hook_metadata, register_hook_metadata, HookMetadata, HookPhase}; pub use payload::{Extensions, PluginPayload}; pub use trait_def::{HookHandler, HookTypeDef, PluginResult}; pub use types::{builtin_hook_types, hook_type_from_str, HookType}; diff --git a/crates/cpex-core/src/identity/hook.rs b/crates/cpex-core/src/identity/hook.rs new file mode 100644 index 00000000..a2a77576 --- /dev/null +++ b/crates/cpex-core/src/identity/hook.rs @@ -0,0 +1,99 @@ +// Location: ./crates/cpex-core/src/identity/hook.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `IdentityHook` — the `HookTypeDef` marker for the IdentityResolve +// hook family. Plugins implement `HookHandler`; the +// framework dispatches into them at request entry to populate +// `Extensions.security.subject` / `.client` / `.caller_workload` / +// `Extensions.raw_credentials` before any tool / resource / prompt +// hook runs. +// +// # Single hook name (for now) +// +// v0 registers under the single name `identity.resolve`. If a future +// slice introduces an `identity.validate` phase that uses the same +// payload + result shape (e.g. a post-resolve consistency check), +// it can share `IdentityHook` and register under `identity.validate` +// via the multi-name registration path — same pattern as CMF's +// `cmf.tool_pre_invoke` / `cmf.llm_input` / etc. sharing `CmfHook`. +// Phases with a different payload shape (e.g. TokenDelegate) get +// their own hook type rather than reusing this one. +// +// # Lifecycle +// +// This file defines the *types*. Lifecycle wiring — when the +// framework calls `invoke_named::(...)`, how results +// merge back into `Extensions` — lands in sub-step B / C of slice 2. + +use crate::hooks::trait_def::PluginResult; + +use super::payload::IdentityPayload; + +/// Primary hook name for IdentityResolve handlers. Used as the +/// registry key when a host registers the handler via the standard +/// `register_handler` path. +pub const HOOK_IDENTITY_RESOLVE: &str = "identity.resolve"; + +crate::define_hook! { + /// Identity-resolve hook. + /// + /// **Payload** ([`IdentityPayload`]) — unified input + accumulator. + /// The host populates the input fields (`raw_token`, `source`, + /// `headers`, ...) once at request entry and never touches them + /// again; handlers populate the output fields (`subject`, + /// `client`, `caller_workload`, `delegation`, `raw_credentials`, + /// `rejected`, ...) on clones of the running payload. Input + /// fields are private and read through accessors — handlers + /// cannot mutate them even on a clone, so the wire-layer input + /// is canonical across the whole chain. + /// + /// **Result** ([`PluginResult`][PluginResult]) — + /// the executor's standard envelope. `modified_payload` carries + /// the updated payload. `continue_processing = false` halts the + /// pipeline (set when the handler decides to reject). + /// + /// **Threading.** Sequential-phase semantics already thread + /// handler N's `modified_payload` into handler N+1's input, so + /// the chain's natural behavior is "each handler sees the prior + /// handler's contributions in the running payload." No bespoke + /// `resolve_identity` method on `PluginManager` — the standard + /// `invoke_named::(...)` does the right thing. + /// + /// **Handler signature:** + /// + /// ```rust,ignore + /// impl HookHandler for MyResolver { + /// async fn handle( + /// &self, + /// payload: &IdentityPayload, + /// _extensions: &Extensions, + /// _ctx: &mut PluginContext, + /// ) -> PluginResult { + /// // Validate the raw token, build the SubjectExtension. + /// let claims = self.validate(payload.raw_token()).await?; + /// let mut updated = payload.clone(); + /// updated.subject = Some(claims.into_subject()); + /// PluginResult::modify_payload(updated) + /// } + /// } + /// ``` + /// + /// Handlers that want to layer onto prior state without manually + /// preserving every untouched field reach for + /// [`IdentityPayload::merge`][merge]. + /// + /// **Registration:** `manager.register_handler::(plugin, config)` + /// against the hook name `"identity.resolve"`. Multiple handlers + /// may register; the framework runs them in priority order and + /// the Sequential-phase chain accumulates their contributions + /// into the running payload. + /// + /// [merge]: super::payload::IdentityPayload::merge + /// [PluginResult]: crate::hooks::trait_def::PluginResult + IdentityHook, "identity.resolve" => { + payload: IdentityPayload, + result: PluginResult, + } +} diff --git a/crates/cpex-core/src/identity/mod.rs b/crates/cpex-core/src/identity/mod.rs new file mode 100644 index 00000000..28fca362 --- /dev/null +++ b/crates/cpex-core/src/identity/mod.rs @@ -0,0 +1,25 @@ +// Location: ./crates/cpex-core/src/identity/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Identity hook family — IdentityResolve. +// +// Mirrors the cmf/ module layout: the hook marker + handler trait +// machinery (provided by cpex-core's generic hooks layer) plus the +// hook-specific payload + result types. Token-delegation lives in +// its own sibling module (slice 3); the two hook families share +// nothing in terms of payloads so they get separate `HookTypeDef` +// markers. +// +// Sub-step A scope: data shapes only — no executor wiring, no +// framework merge-into-Extensions logic, no APL integration. Those +// land in sub-steps B / C / D. + +pub mod hook; +pub mod payload; +pub mod route_config; + +pub use hook::{IdentityHook, HOOK_IDENTITY_RESOLVE}; +pub use payload::{IdentityPayload, TokenSource}; +pub use route_config::{RouteIdentityConfig, RouteIdentityStep}; diff --git a/crates/cpex-core/src/identity/payload.rs b/crates/cpex-core/src/identity/payload.rs new file mode 100644 index 00000000..ed886d5e --- /dev/null +++ b/crates/cpex-core/src/identity/payload.rs @@ -0,0 +1,460 @@ +// Location: ./crates/cpex-core/src/identity/payload.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `IdentityPayload` — the unified state struct threaded through the +// IdentityResolve hook chain. Plays two roles in one type: +// +// * **Input** (private fields, read-only after construction) — +// `raw_token`, `source`, `source_header`, `headers`, `client_host`, +// `client_port`. Populated by the host once at request entry and +// never mutated by handlers. Privacy is enforced at the module +// boundary: external code reads through `pub fn raw_token() -> &str` +// etc. and has no setters or mutable field access, so even a +// `payload.clone()` followed by `clone.raw_token = ...` fails to +// compile. +// +// * **Accumulating output** (`pub` fields) — `subject`, `client`, +// `caller_workload`, `delegation`, `raw_credentials`, `rejected`, +// `reject_status`, `reject_reason`, `resolved_at`, `raw_claims`. +// Handlers clone the payload, populate the output fields they care +// about, and return the updated payload via +// `PluginResult::modify_payload`. Sequential-phase executor +// semantics thread plugin N's output into plugin N+1's input, +// producing a natural accumulator chain. +// +// # Why one struct instead of separate Payload + Result +// +// An earlier draft had `IdentityPayload` (input) and `IdentityResult` +// (output) as distinct types — the Python framework's split +// (`cpex/framework/hooks/identity.py`). That made the first handler +// awkward: it received an "empty IdentityResult" with no way to read +// the raw token without dropping back to `Extensions`. Folding the +// two types into one means handler N always has the inputs it needs +// (private getters) plus whatever previous handlers have already +// accumulated (read direct pub fields), and the hook signature stays +// uniform with everything else in the framework — `invoke_named::` +// with `PluginResult` on the way out. +// +// # Rejection model +// +// Handlers reject via `PluginResult::deny(PluginViolation::new(code, +// reason))` — the same path every other hook uses. The executor's +// `continue_processing = false` check halts the chain at the +// framework level, so no later handler can run and accidentally +// overwrite the decision. There is intentionally no `rejected` / +// `reject_status` / `reject_reason` flag on the payload itself — +// duplicating the rejection state in a `pub` field would let a +// later handler clone the payload, clear the flag, and quietly +// turn a 401 into a 200. The framework's existing halt machinery +// already does the right thing. +// +// Host-side HTTP mapping is conventional: `PluginViolation.code` +// is the resolution-specific identifier (`auth.expired`, +// `auth.audience_mismatch`, `auth.missing_scope`), and the host +// maps it to a status code (401 / 403 / etc.). Same pattern as +// CMF tool-pre-invoke denials. + +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::executor::PipelineResult; +use crate::extensions::{ + ClientExtension, DelegationExtension, Extensions, RawCredentialsExtension, SecurityExtension, + SubjectExtension, WorkloadIdentity, +}; +use crate::impl_plugin_payload; + +/// Where the raw credential was extracted from. Lets handlers +/// short-circuit on payloads they don't service (an mTLS-only +/// resolver ignores `Bearer` payloads). `Custom(String)` is the +/// escape hatch for bespoke wire formats. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenSource { + /// `Authorization: Bearer ` style. + Bearer, + /// `X-User-Token` style — explicit user-identity header alongside + /// a separate gateway-access token in `Authorization`. + UserToken, + /// mTLS — credential is the peer X.509 chain (surfaced via + /// `X-Forwarded-Client-Cert`). `raw_token` may be empty in this + /// case; the chain itself flows through `headers`. + Mtls, + /// SPIFFE JWT-SVID — JWT-shaped but with SPIFFE-specific claims. + SpiffeJwtSvid, + /// API key in a header or query param. + ApiKey, + /// Operator-defined extraction path. + #[serde(untagged)] + Custom(String), +} + +impl Default for TokenSource { + fn default() -> Self { + TokenSource::Bearer + } +} + +/// State threaded through the IdentityResolve hook chain. +/// +/// See the module-level docs for the input/output split. In short: +/// **input fields are private** (set once via the constructor + +/// builders, never mutated), **output fields are `pub`** (handlers +/// populate them on clones and return the updated payload). +/// +/// Implements `PluginPayload` so it can flow through the executor's +/// existing Sequential-phase machinery — no bespoke plumbing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityPayload { + // ----- Input (private — host-supplied, never mutated by handlers) ----- + /// Raw credential bytes. Cleared on drop via `Zeroizing`. + /// `#[serde(skip)]` — never appears in serialized output. + #[serde(skip)] + raw_token: Zeroizing, + + /// Where the credential was extracted from. + source: TokenSource, + + /// HTTP header (or other wire-level slot) the token arrived in. + #[serde(default, skip_serializing_if = "Option::is_none")] + source_header: Option, + + /// Full request headers — escape hatch for custom auth flows. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + headers: HashMap, + + /// Client IP, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + client_host: Option, + + /// Client TCP port, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + client_port: Option, + + // ----- Output (pub — handlers populate via direct assignment on clones) ----- + /// Resolved user identity. `None` until a handler populates it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + + /// Resolved OAuth client / gateway-access identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + + /// Resolved attested workload identity for the inbound peer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caller_workload: Option, + + /// Initial delegation chain parsed from `act` / equivalent claims. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation: Option, + + /// Raw inbound tokens to stash in + /// `Extensions.raw_credentials.inbound_tokens` after the chain + /// completes (gated by `read_inbound_credentials` for consumers). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_credentials: Option, + + /// Optional resolution timestamp. Audit-useful. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_at: Option>, + + /// Raw decoded token claims, when a handler wants to expose them + /// for audit/policy without elevating each claim to a typed + /// field. Mirrors the Python `raw_claims: dict[str, Any]`. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub raw_claims: HashMap, +} + +impl IdentityPayload { + /// Construct a payload with the required input fields populated. + /// The most common entry point — hosts call this once per request + /// before invoking the hook. Optional input slots + /// (`source_header`, `headers`, `client_host`, `client_port`) are + /// set via the `.with_*` builders below; output fields start as + /// `None` / `false` / empty and accumulate as handlers run. + pub fn new(raw_token: impl Into, source: TokenSource) -> Self { + Self { + raw_token: Zeroizing::new(raw_token.into()), + source, + source_header: None, + headers: HashMap::new(), + client_host: None, + client_port: None, + subject: None, + client: None, + caller_workload: None, + delegation: None, + raw_credentials: None, + resolved_at: None, + raw_claims: HashMap::new(), + } + } + + // -------- Input builders -------- + + pub fn with_source_header(mut self, h: impl Into) -> Self { + self.source_header = Some(h.into()); + self + } + + pub fn with_headers(mut self, h: HashMap) -> Self { + self.headers = h; + self + } + + pub fn with_client_host(mut self, h: impl Into) -> Self { + self.client_host = Some(h.into()); + self + } + + pub fn with_client_port(mut self, port: u16) -> Self { + self.client_port = Some(port); + self + } + + // -------- Input read accessors (no mutable variants) -------- + + /// The raw credential bytes. Borrowed — handlers cannot move + /// or replace the underlying `Zeroizing` through this + /// accessor. + pub fn raw_token(&self) -> &str { + &self.raw_token + } + + pub fn source(&self) -> &TokenSource { + &self.source + } + + pub fn source_header(&self) -> Option<&str> { + self.source_header.as_deref() + } + + pub fn headers(&self) -> &HashMap { + &self.headers + } + + pub fn client_host(&self) -> Option<&str> { + self.client_host.as_deref() + } + + pub fn client_port(&self) -> Option { + self.client_port + } + + // -------- Output helpers -------- + + /// Layer another payload's *output* fields onto this one's, + /// following "Some replaces None, last write wins per slot." + /// Input fields are not touched — the running payload's input + /// is canonical for the whole chain. + /// + /// Rejection is *not* a merged field — handlers reject via + /// `PluginResult::deny`, which halts the chain at the framework + /// level rather than being expressed as payload state. See the + /// module docs for the rationale. + pub fn merge(&mut self, other: IdentityPayload) { + if other.subject.is_some() { + self.subject = other.subject; + } + if other.client.is_some() { + self.client = other.client; + } + if other.caller_workload.is_some() { + self.caller_workload = other.caller_workload; + } + if other.delegation.is_some() { + self.delegation = other.delegation; + } + if other.raw_credentials.is_some() { + self.raw_credentials = other.raw_credentials; + } + if other.resolved_at.is_some() { + self.resolved_at = other.resolved_at; + } + for (k, v) in other.raw_claims { + self.raw_claims.insert(k, v); + } + } + + // -------- Host-side application helpers -------- + + /// Pull the resolved `IdentityPayload` out of a `PipelineResult` + /// returned by `mgr.invoke_named::(...)`. Returns + /// `None` when the pipeline was denied (no `modified_payload`) + /// or when the result's payload wasn't an `IdentityPayload` — a + /// programmer error if the latter, since the executor produces + /// `modified_payload` typed per the hook's `HookTypeDef::Payload`. + /// + /// Clones the inner payload — the original `Box` + /// stays in the `PipelineResult` so callers can also inspect + /// `continue_processing`, `violation`, etc. + pub fn from_pipeline_result(result: &PipelineResult) -> Option { + result + .modified_payload + .as_ref() + .and_then(|p| p.as_any().downcast_ref::()) + .cloned() + } + + /// Apply this payload's resolved identity slots back into an + /// `Extensions` container. Returns a new `Extensions` ready to + /// hand to the next hook in the request lifecycle (`cmf.tool_pre_invoke`, + /// etc.) — downstream plugins read `security.subject` / + /// `security.client` / `security.caller_workload` / + /// `raw_credentials` etc. through the standard capability-gated + /// filter. + /// + /// Merging rules: + /// + /// - **`security.subject` / `.client` / `.caller_workload`** — + /// `Some` values on the payload overwrite the existing slot; + /// other security fields (labels, classification, this_workload, + /// auth_method, objects, data) are preserved from the input + /// Extensions. + /// - **`raw_credentials`** — replaced wholesale when populated on + /// the payload. Wholesale rather than merged because handlers + /// produce the complete set of inbound tokens for this request; + /// the host's pre-invoke Extensions wouldn't normally carry one. + /// - **`delegation`** — replaced wholesale when populated. + /// Initial chain from `act` claims in the inbound credential. + /// + /// Input fields on the payload (`raw_token`, `headers`, …) are + /// **not** copied into Extensions — they're the resolver's + /// internal workspace, not request-wide state. + pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions { + let needs_security_update = self.subject.is_some() + || self.client.is_some() + || self.caller_workload.is_some(); + + if needs_security_update { + // Clone-out the existing security extension (or default a + // fresh one) so we can write our identity slots while + // preserving labels / classification / etc. + let mut sec: SecurityExtension = ext + .security + .as_ref() + .map(|arc| (**arc).clone()) + .unwrap_or_default(); + if let Some(s) = &self.subject { + sec.subject = Some(s.clone()); + } + if let Some(c) = &self.client { + sec.client = Some(c.clone()); + } + if let Some(w) = &self.caller_workload { + sec.caller_workload = Some(w.clone()); + } + ext.security = Some(Arc::new(sec)); + } + + if let Some(rc) = &self.raw_credentials { + ext.raw_credentials = Some(Arc::new(rc.clone())); + } + + if let Some(d) = &self.delegation { + ext.delegation = Some(Arc::new(d.clone())); + } + + ext + } +} + +impl_plugin_payload!(IdentityPayload); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_token_serializes_without_secret() { + let p = IdentityPayload::new( + "eyJhbGciOiJSUzI1NiJ9.payload.sig", + TokenSource::Bearer, + ); + let json = serde_json::to_string(&p).unwrap(); + assert!( + !json.contains("eyJhbGciOiJSUzI1NiJ9"), + "raw_token leaked into serialized form: {}", + json, + ); + assert!(json.contains("bearer")); + } + + #[test] + fn deserialize_yields_empty_raw_token() { + let json = r#"{"source":"bearer"}"#; + let p: IdentityPayload = serde_json::from_str(json).unwrap(); + assert_eq!(p.raw_token(), ""); + assert_eq!(p.source(), &TokenSource::Bearer); + } + + #[test] + fn token_source_custom_round_trips() { + let s = TokenSource::Custom("magic-link".into()); + let json = serde_json::to_string(&s).unwrap(); + let back: TokenSource = serde_json::from_str(&json).unwrap(); + assert_eq!(s, back); + } + + #[test] + fn input_builders_chain() { + let mut h = HashMap::new(); + h.insert("user-agent".to_string(), "curl/8.0".to_string()); + let p = IdentityPayload::new("tok", TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(h) + .with_client_host("10.0.0.1") + .with_client_port(443); + assert_eq!(p.raw_token(), "tok"); + assert_eq!(p.source_header(), Some("Authorization")); + assert_eq!(p.client_host(), Some("10.0.0.1")); + assert_eq!(p.client_port(), Some(443)); + assert_eq!(p.headers().get("user-agent").map(String::as_str), Some("curl/8.0")); + } + + #[test] + fn handler_can_populate_output_on_clone() { + // Exercises the typical handler pattern: clone the running + // payload, set the output fields the handler is responsible + // for, return the updated payload. Input fields survive + // the clone unchanged. + let original = IdentityPayload::new("eyJ.tok", TokenSource::Bearer); + let mut updated = original.clone(); + updated.subject = Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }); + assert_eq!(updated.raw_token(), "eyJ.tok"); // input preserved + assert_eq!(updated.subject.as_ref().unwrap().id.as_deref(), Some("alice")); + // Original unchanged — the clone is a separate value. + assert!(original.subject.is_none()); + } + + #[test] + fn merge_overlays_some_onto_none() { + // Cross-handler chaining: handler 1 resolves the subject, + // handler 2 contributes the workload. Merged result carries + // both. + let mut base = IdentityPayload::new("tok", TokenSource::Bearer); + base.subject = Some(SubjectExtension { + id: Some("alice".into()), + ..Default::default() + }); + let mut overlay = IdentityPayload::new("tok", TokenSource::Bearer); + overlay.caller_workload = Some(WorkloadIdentity { + spiffe_id: Some("spiffe://corp.com/inbound".into()), + ..Default::default() + }); + base.merge(overlay); + assert_eq!(base.subject.as_ref().unwrap().id.as_deref(), Some("alice")); + assert!(base.caller_workload.is_some()); + } + +} diff --git a/crates/cpex-core/src/identity/route_config.rs b/crates/cpex-core/src/identity/route_config.rs new file mode 100644 index 00000000..1c9aa357 --- /dev/null +++ b/crates/cpex-core/src/identity/route_config.rs @@ -0,0 +1,202 @@ +// Location: ./crates/cpex-core/src/identity/route_config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Route-level identity configuration — the parsed shape of a +// route's `identity:` block in unified-config YAML. +// +// See `docs/apl-identity-delegation-design.md` for the full design. +// +// # Semantic note +// +// Identity binding is **hook-specific**: the `identity:` block +// binds plugins ONLY for the `identity.resolve` hook on this +// route, independent of whatever the route's `plugins:` block does. +// This matters because in APL-driven routes, the `plugins:` block +// has different meaning (it's a per-route config-override list, +// not a dispatch list — APL controls the dispatch). Identity +// needs its own binding mechanism so the meaning is unambiguous +// regardless of whether APL is annotating the route. +// +// # YAML shapes +// +// Two accepted forms parse to the same IR. The visitor / parser +// logic in `crate::config` discriminates them. +// +// ```yaml +// # List form — implicit additive, common case +// identity: +// - corp-jwt +// - spiffe-attestor +// +// # Object form — when the override flag is needed +// identity: +// replace_inherited: true +// steps: +// - legacy-basic-auth +// ``` +// +// Each step is either a bare plugin name (string) or a map with +// `name:` + optional `on_error:` / `config:`: +// +// ```yaml +// identity: +// - corp-jwt # bare name +// - name: spiffe-attestor # map form +// on_error: deny +// config: +// verify_attestation: strict +// ``` + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// A route's parsed `identity:` block. Drives dispatch of the +/// `identity.resolve` hook for the route. +/// +/// `None` on a `RouteEntry` means "no identity declared for this +/// route" — `invoke_named::` will return an empty +/// entry list when filtered for this route, and the host's +/// `IdentityPayload` flows through unchanged (no resolvers fire). +/// +/// Inheritance (Slice C, deferred) walks `global → tags → route` +/// and merges each layer's `RouteIdentityConfig` based on +/// `replace_inherited`: when `false` (the default), the new layer's +/// steps append after the inherited ones; when `true`, the new +/// layer's steps replace the inherited list wholesale. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RouteIdentityConfig { + /// Ordered list of identity steps to run. Empty list is valid: + /// `identity: { replace_inherited: true, steps: [] }` is the + /// "explicitly opt out of inherited identity" knob. + pub steps: Vec, + + /// When true, this block replaces any inherited identity steps + /// instead of appending to them. Set via the object-form YAML + /// (`identity: { replace_inherited: true, steps: [...] }`). + /// The list-form YAML always produces `false`. + /// + /// Honored by the inheritance merge once Slice C lands. Slice A + /// stores the flag without exercising its merge semantics (no + /// inheritance to override yet at route level). + #[serde(default, skip_serializing_if = "is_false")] + pub replace_inherited: bool, +} + +/// One step in the identity-phase pipeline. Points at a plugin +/// registered under the `identity.resolve` hook, optionally with +/// a per-call config override and an `on_error` policy that +/// controls what happens when the step fails. +/// +/// # Cumulative stacking +/// +/// At runtime, every step in the block runs (subject to its own +/// `on_error`). Each step's resolved `IdentityPayload` accumulates +/// — handlers contribute orthogonal slots (JWT → `subject`; +/// SPIFFE → `caller_workload`; agent resolver → `agent`) so they +/// compose without collision in the common case. +/// +/// # On-error semantics +/// +/// - `None` or `Some("continue")` — soft failure: the step's +/// contribution is dropped, the next step runs, and any missing +/// extensions get caught later by `require(authenticated)` / +/// `require(workload.*)` in downstream policy. +/// - `Some("deny")` — hard requirement: a failure halts the +/// request with the plugin's violation code. +/// +/// Unknown strings parse as best-effort; future slices may +/// introduce typed enums. +/// +/// # Per-step config override +/// +/// `config_override` reuses the existing per-call override +/// pathway. When present, the framework's +/// `create_override_instance` builds a new plugin instance with +/// the merged config and dispatches into it for this route. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RouteIdentityStep { + /// Plugin name — must match an entry in the top-level + /// `plugins:` block that registers under `identity.resolve`. + pub name: String, + + /// Optional config override applied for this step only. + /// `None` means "use the plugin's configured defaults from the + /// `plugins:` declaration." Stored as `serde_json::Value` to + /// match the existing `create_override_instance` interface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_override: Option, + + /// Per-step failure handling. See type-level docs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_error: Option, + + /// Catch-all for any other fields a future schema version + /// adds (timeout, priority, condition, …) — preserved so the + /// parser doesn't reject configs targeting newer runtimes. + #[serde(default, flatten, skip_serializing_if = "HashMap::is_empty")] + pub extra: HashMap, +} + +impl RouteIdentityStep { + /// Convenience for tests / programmatic construction: build a + /// bare step that just names a plugin with no overrides. + pub fn bare(name: impl Into) -> Self { + Self { + name: name.into(), + ..Default::default() + } + } +} + +/// `#[serde(skip_serializing_if = "is_false")]` helper — keeps +/// the YAML round-trip clean by omitting the default `false`. +fn is_false(b: &bool) -> bool { + !*b +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_step_has_no_overrides() { + let s = RouteIdentityStep::bare("corp-jwt"); + assert_eq!(s.name, "corp-jwt"); + assert!(s.config_override.is_none()); + assert!(s.on_error.is_none()); + assert!(s.extra.is_empty()); + } + + #[test] + fn config_default_is_empty_additive() { + let c = RouteIdentityConfig::default(); + assert!(c.steps.is_empty()); + assert!(!c.replace_inherited); + } + + #[test] + fn serializes_without_default_replace_inherited() { + // `replace_inherited: false` should round-trip as absent — + // it's the default and clutters the YAML otherwise. + let c = RouteIdentityConfig { + steps: vec![RouteIdentityStep::bare("corp-jwt")], + replace_inherited: false, + }; + let yaml = serde_yaml::to_string(&c).unwrap(); + assert!(!yaml.contains("replace_inherited"), "got: {yaml}"); + assert!(yaml.contains("corp-jwt"), "got: {yaml}"); + } + + #[test] + fn serializes_with_explicit_replace_inherited() { + let c = RouteIdentityConfig { + steps: vec![RouteIdentityStep::bare("legacy-basic-auth")], + replace_inherited: true, + }; + let yaml = serde_yaml::to_string(&c).unwrap(); + assert!(yaml.contains("replace_inherited: true"), "got: {yaml}"); + } +} diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index f2f8f80c..12378bfd 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -20,16 +20,23 @@ // - [`factory`] — Plugin factory registry for config-driven instantiation // - [`context`] — PluginContext (local_state + global_state) // - [`cmf`] — ContextForge Message Format (Message, ContentPart, enums) +// - [`identity`] — IdentityResolve hook family (subject / client / +// workload resolution from raw credentials) +// - [`delegation`] — TokenDelegate hook family (outbound credential +// minting for downstream calls) // - [`error`] — Error types, violations, and result types pub mod cmf; pub mod config; pub mod context; +pub mod delegation; pub mod error; pub mod executor; pub mod extensions; pub mod factory; pub mod hooks; +pub mod identity; pub mod manager; pub mod plugin; pub mod registry; +pub mod visitor; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 16764d49..1dfb7b05 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -1,7 +1,7 @@ // Location: ./crates/cpex-core/src/manager.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor +// Authors: Teryl Taylor, Fred Araujo // // Plugin manager. // @@ -26,7 +26,7 @@ use std::hash::{Hash, Hasher}; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use hashbrown::HashMap; @@ -170,6 +170,46 @@ struct RuntimeSnapshot { /// Maximum number of entries the route cache will hold. Once reached, /// new resolutions are computed normally but not memoized (reject-on-full). route_cache_max_entries: usize, + + /// Per-route, per-hook handler overrides keyed by + /// `(entity_type, entity_name, scope, hook_name)`. When a request matches + /// an annotation, route resolution short-circuits to a single-entry list + /// containing the annotated handler instead of resolving the route's + /// imperative `plugins:` chain. + /// + /// Per-hook keying lets an orchestrator install distinct handlers for + /// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` on the same route — + /// useful when the pre/post phases need different handler state (e.g. + /// apl-cpex's `AplRouteHandler` binds each instance to either + /// `evaluate_pre` or `evaluate_post`). + /// + /// `scope` (None vs `Some("virtual-server-A")`) lets two virtual + /// servers / gateways with the same tool name carry distinct + /// orchestrators. Matching mirrors cpex-core's existing + /// `find_matching_route` semantics: a scoped request first tries the + /// exact `(et, en, Some(req_scope), hook)` annotation; on miss it falls + /// back to the unscoped `(et, en, None, hook)` default. An unscoped + /// request only matches `(et, en, None, hook)`. Net effect: None-scope + /// annotations act as a global default, scoped annotations override + /// per-scope. + /// + /// The plugins listed under the matching route are *still* registered + /// in the registry — they remain discoverable via `find_plugin_entries` + /// so the annotated handler can dispatch into them by-name (this is + /// what apl-cpex's `AplRouteHandler` does via `CmfPluginInvoker` for + /// `plugin(name)` references inside APL rules). + route_annotations: HashMap, +} + +/// Composite key for route annotations. Includes the hook name so a single +/// route can carry distinct handlers per phase (e.g. pre-invoke vs +/// post-invoke). +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +struct AnnotationKey { + entity_type: String, + entity_name: String, + scope: Option, + hook_name: String, } pub struct PluginManager { @@ -204,6 +244,15 @@ pub struct PluginManager { /// can be `&self` and the manager itself can sit behind `Arc`. initialized: AtomicBool, + /// Monotonic config-generation counter. Bumped every time the runtime + /// snapshot is swapped (factory mutation, config (re)load, plugin + /// register/unregister). External orchestrators (apl-cpex's dispatch + /// plan cache) pair their cached values with the generation seen at + /// build time; a generation mismatch on lookup signals "evict + rebuild." + /// Starts at 0; first snapshot publish (empty registry) leaves it at 0, + /// so callers can use 0 as a "never observed" sentinel. + generation: AtomicU64, + /// Tracks in-flight fire-and-forget background tasks across all /// invocations so `shutdown()` can wait for them to drain before /// returning. Without this, audit/telemetry tasks spawned by recent @@ -213,6 +262,13 @@ pub struct PluginManager { /// /// `TaskTracker` is internally `Arc`'d, so cloning is a refcount bump. task_tracker: tokio_util::task::TaskTracker, + + /// External orchestrators registered via `register_visitor`. Walked + /// in registration order during `load_config_yaml` (after plugin + /// instantiation) so each visitor can inspect raw YAML sections and + /// install handlers via `annotate_route`. Empty by default — the + /// `load_config(CpexConfig)` path skips visitors entirely. + visitors: RwLock>>, } /// Emit warnings for YAML settings that the runtime doesn't currently @@ -300,6 +356,7 @@ fn snapshot_from_config(registry: PluginRegistry, cpex_config: CpexConfig) -> Ru executor, cpex_config: Some(cpex_config), route_cache_max_entries, + route_annotations: HashMap::new(), } } @@ -312,6 +369,7 @@ impl PluginManager { executor: Executor::new(config.executor), cpex_config: None, route_cache_max_entries: config.route_cache_max_entries, + route_annotations: HashMap::new(), }; Self { runtime: arc_swap::ArcSwap::from_pointee(snapshot), @@ -320,7 +378,9 @@ impl PluginManager { cache_hasher, route_cache_full_warned: AtomicBool::new(false), initialized: AtomicBool::new(false), + generation: AtomicU64::new(0), task_tracker: tokio_util::task::TaskTracker::new(), + visitors: RwLock::new(Vec::new()), } } @@ -341,6 +401,10 @@ impl PluginManager { let mut next = (*current).clone(); let result = f(&mut next); self.runtime.store(Arc::new(next)); + // Release ordering pairs with the Acquire load in + // config_generation() — external cache consumers that observe a + // higher generation are guaranteed to see the new snapshot. + self.generation.fetch_add(1, Ordering::Release); result } @@ -355,9 +419,23 @@ impl PluginManager { let mut next = (*current).clone(); let result = f(&mut next)?; self.runtime.store(Arc::new(next)); + // Same Release-ordered bump as mutate_runtime — only on Ok, since + // Err leaves the snapshot untouched. + self.generation.fetch_add(1, Ordering::Release); Ok(result) } + /// Monotonic counter that increments on every runtime snapshot swap + /// (registry mutation, config (re)load). External orchestrators + /// (e.g. apl-cpex's dispatch-plan cache) pair their cached values + /// with the generation seen at build time; a mismatch on lookup + /// signals "evict + rebuild." `Acquire` pairs with the `Release` + /// fetch_add in `mutate_runtime` / `try_mutate_runtime` so observing + /// a higher generation guarantees visibility of the new snapshot. + pub fn config_generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + // ----------------------------------------------------------------------- // Factory Registration // ----------------------------------------------------------------------- @@ -435,6 +513,10 @@ impl PluginManager { self.runtime .store(Arc::new(snapshot_from_config(new_registry, cpex_config))); + // Same generation bump as mutate_runtime — load_config doesn't + // go through that helper because it has to swap registry + executor + // + cache-cap atomically as one snapshot. + self.generation.fetch_add(1, Ordering::Release); // Clear routing cache — config changed. self.clear_routing_cache(); @@ -442,6 +524,154 @@ impl PluginManager { Ok(()) } + /// Register an external config visitor. Visitors run during + /// `load_config_yaml` (after plugin instantiation) and can install + /// per-route handler overrides via `annotate_route`. Visitor order + /// matches registration order. Multiple visitors are allowed — + /// they typically don't share state, so order rarely matters. + pub fn register_visitor(&self, visitor: Arc) { + let mut v = self.visitors.write().unwrap_or_else(|p| p.into_inner()); + v.push(visitor); + } + + /// Load a unified-config YAML string. Parses the YAML twice — once + /// into a typed `CpexConfig` for plugin instantiation, once into a + /// raw `serde_yaml::Value` so visitors can inspect orchestrator- + /// specific blocks (e.g. `apl:`) that cpex-core itself doesn't + /// model. Calls existing `load_config(cpex_config)` first, then + /// walks each registered visitor over the raw YAML's sections in + /// the documented hierarchy order: + /// + /// 1. `visit_global(global_yaml)` + /// 2. `visit_default(entity_type, default_yaml)` per `global.defaults` entry + /// 3. `visit_policy_bundle(tag, bundle_yaml)` per `global.policies` entry + /// 4. `visit_route(route_yaml, parsed_route)` per `routes[]` entry + /// + /// All sections for one visitor run before the next visitor starts, + /// giving each visitor a consistent view of its own accumulated + /// state. A visitor returning Err aborts the load — the plugin + /// snapshot stays at the post-`load_config` state (partial load is + /// not rolled back; operators should treat any error from this + /// method as a hard stop). + pub fn load_config_yaml(self: &Arc, yaml: &str) -> Result<(), Box> { + // Parse once into a Value so the raw shape is available to + // visitors. Then deserialize from that Value into CpexConfig — + // saves a second tokenize/lex pass vs parsing the string twice. + let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| { + Box::new(PluginError::Config { + message: format!("YAML parse error: {}", e), + }) + })?; + let cpex_config: CpexConfig = serde_yaml::from_value(raw.clone()).map_err(|e| { + Box::new(PluginError::Config { + message: format!("CpexConfig deserialize error: {}", e), + }) + })?; + + // Snapshot the parsed routes + plugin declarations before + // load_config moves the config — visitors get the typed + // structures side-by-side with the raw YAML so they don't have + // to re-deserialize anything cpex-core has already validated. + let parsed_routes: Vec = cpex_config.routes.clone(); + let parsed_plugins: Vec = cpex_config.plugins.clone(); + + // Existing plugin-instantiation path. + self.load_config(cpex_config)?; + + // Visitor walk. No-op when no visitors registered — the common + // case for hosts that don't use the orchestrator extension point. + let visitors = { + let v = self.visitors.read().unwrap_or_else(|p| p.into_inner()); + if v.is_empty() { + return Ok(()); + } + v.clone() + }; + + let mgr: Arc = Arc::clone(self); + let global_yaml = raw.get("global").cloned().unwrap_or(serde_yaml::Value::Null); + let defaults_yaml = global_yaml + .get("defaults") + .and_then(serde_yaml::Value::as_mapping) + .cloned(); + let policies_yaml = global_yaml + .get("policies") + .and_then(serde_yaml::Value::as_mapping) + .cloned(); + let routes_yaml: Vec = raw + .get("routes") + .and_then(serde_yaml::Value::as_sequence) + .cloned() + .unwrap_or_default(); + + for visitor in &visitors { + visitor.visit_plugins(&mgr, &parsed_plugins).map_err(|e| { + Box::new(PluginError::Config { + message: format!("visitor '{}' visit_plugins: {}", visitor.name(), e), + }) + })?; + + visitor.visit_global(&mgr, &global_yaml).map_err(|e| { + Box::new(PluginError::Config { + message: format!("visitor '{}' visit_global: {}", visitor.name(), e), + }) + })?; + + if let Some(defaults) = &defaults_yaml { + for (k, v) in defaults { + let Some(entity_type) = k.as_str() else { continue }; + visitor.visit_default(&mgr, entity_type, v).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "visitor '{}' visit_default('{}'): {}", + visitor.name(), + entity_type, + e + ), + }) + })?; + } + } + + if let Some(policies) = &policies_yaml { + for (k, v) in policies { + let Some(tag) = k.as_str() else { continue }; + visitor.visit_policy_bundle(&mgr, tag, v).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "visitor '{}' visit_policy_bundle('{}'): {}", + visitor.name(), + tag, + e + ), + }) + })?; + } + } + + for (i, parsed) in parsed_routes.iter().enumerate() { + let route_yaml = routes_yaml + .get(i) + .cloned() + .unwrap_or(serde_yaml::Value::Null); + visitor + .visit_route(&mgr, &route_yaml, parsed) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "visitor '{}' visit_route[{}]: {}", + visitor.name(), + i, + e + ), + }) + })?; + } + } + + Ok(()) + } + /// Create a PluginManager from a parsed config (convenience). /// /// Uses the passed factory registry for initial instantiation. @@ -713,7 +943,11 @@ impl PluginManager { let hook_type = HookType::new(hook_name); let all_entries = snapshot.registry.entries_for_hook(&hook_type); - if all_entries.is_empty() { + // Same caveat as `invoke_named`: route annotations can produce a + // dispatch entry without any plugin being registered on the + // hook directly, so we can only short-circuit when both the + // registry and the annotation map are empty. + if all_entries.is_empty() && snapshot.route_annotations.is_empty() { return ( PipelineResult::allowed_with( payload, @@ -791,7 +1025,10 @@ impl PluginManager { let hook_type = HookType::new(H::NAME); let all_entries = snapshot.registry.entries_for_hook(&hook_type); - if all_entries.is_empty() { + // See `invoke_named` for why we don't short-circuit on + // `all_entries.is_empty()` alone — route annotations can fire + // without a directly-registered plugin. + if all_entries.is_empty() && snapshot.route_annotations.is_empty() { let boxed: Box = Box::new(payload); return ( PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), @@ -862,7 +1099,13 @@ impl PluginManager { let hook_type = HookType::new(hook_name); let all_entries = snapshot.registry.entries_for_hook(&hook_type); - if all_entries.is_empty() { + // No registered entries AND no route annotations → nothing to + // do. Allow-and-pass-through. We can't short-circuit on + // `all_entries.is_empty()` alone, because route annotations + // (external-orchestrator handlers from APL / future Rego / + // Cedar-direct) can produce a single-entry dispatch even when + // no plugin was registered on the hook directly. + if all_entries.is_empty() && snapshot.route_annotations.is_empty() { let boxed: Box = Box::new(payload); return ( PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), @@ -895,6 +1138,151 @@ impl PluginManager { .await } + /// Find every (hook_name, HookEntry) pair belonging to the named + /// plugin. Returns an empty `Vec` if the plugin isn't registered. + /// + /// Used by external orchestrators (notably apl-cpex) that decide + /// the per-route plugin lineup themselves and need handler refs + + /// trusted_config to build pre-resolved dispatch plans. Cheaper than + /// going through `invoke_named` per request because the caller can + /// cache the resulting entries — pair the result with + /// [`config_generation`](Self::config_generation) to invalidate the + /// cache on snapshot swaps. + /// + /// Bypasses route/entity filtering — caller has already decided this + /// plugin should run. APL's `routes:` is itself the authoritative + /// lineup; cpex-core's condition-based routing is a parallel model + /// for non-APL hosts. + pub fn find_plugin_entries( + &self, + plugin_name: &str, + ) -> Vec<(String, crate::registry::HookEntry)> { + let snapshot = self.load_runtime(); + snapshot.registry.entries_for_plugin(plugin_name) + } + + /// Dispatch a caller-supplied slice of HookEntries through the + /// executor's full 5-phase pipeline (sequential, transform, audit, + /// concurrent, fire-and-forget). All on_error / timeout / mode / + /// write-token machinery applies. + /// + /// Bypasses hook-name lookup and route/entity filtering — caller has + /// already resolved the lineup (typically via + /// [`find_plugin_entries`](Self::find_plugin_entries) + a per-route + /// dispatch plan). The `H: HookTypeDef` parameter enforces payload + /// type at compile time; mismatched payloads fail to compile, same + /// as [`invoke_named`](Self::invoke_named). + /// + /// Returns `(PipelineResult, BackgroundTasks)` identical in shape to + /// `invoke_named` so callers can swap between the two paths without + /// rewriting downstream result handling. + pub async fn invoke_entries( + &self, + entries: &[crate::registry::HookEntry], + payload: H::Payload, + extensions: Extensions, + context_table: Option, + ) -> (PipelineResult, BackgroundTasks) { + if entries.is_empty() { + let boxed: Box = Box::new(payload); + return ( + PipelineResult::allowed_with(boxed, extensions, context_table.unwrap_or_default()), + BackgroundTasks::empty(), + ); + } + let snapshot = self.load_runtime(); + let boxed: Box = Box::new(payload); + snapshot + .executor + .execute( + entries, + boxed, + extensions, + context_table, + &self.task_tracker, + ) + .await + } + + // ----------------------------------------------------------------------- + // Route Annotation + // ----------------------------------------------------------------------- + + /// Override the resolved plugin list for one `(entity_type, entity_name)` + /// pair on the listed hooks with a single synthetic handler. The handler + /// takes responsibility for any further plugin dispatch within itself + /// (typically by calling [`invoke_entries`](Self::invoke_entries) against + /// the same registry's other entries — i.e. APL's `plugin(name)` → + /// `CmfPluginInvoker` → `invoke_entries` flow). + /// + /// This is the integration point external orchestrators (APL, future + /// Rego/Cedar-direct/Custom) use to drive plugins via their own + /// semantics instead of cpex-core's imperative `routes.*.plugins:` + /// chain. Bumps the config generation so cached dispatch plans in + /// downstream caches invalidate. + /// + /// `config` provides the trusted_config for the synthetic plugin — + /// the executor reads `mode`, `on_error`, `capabilities`, etc. from + /// it the same way it does for any other registered plugin. Capabilities + /// should be a *superset* of what the orchestrator needs to read from + /// `Extensions` (cpex-core's per-plugin filter still applies to the + /// synthetic handler). + /// + /// The underlying `plugins:` chain for this route is *not* removed — + /// those plugins stay discoverable via [`find_plugin_entries`](Self::find_plugin_entries) + /// so the orchestrator can dispatch into them by name. + pub fn annotate_route( + &self, + entity_type: impl Into, + entity_name: impl Into, + scope: Option, + hook_name: impl Into, + handler: Arc, + config: crate::plugin::PluginConfig, + ) + where + H: crate::plugin::Plugin + crate::registry::AnyHookHandler + 'static, + { + let key = AnnotationKey { + entity_type: entity_type.into(), + entity_name: entity_name.into(), + scope, + hook_name: hook_name.into(), + }; + let plugin_ref = Arc::new(crate::registry::PluginRef::new( + handler.clone() as Arc, + config, + )); + let entry = crate::registry::HookEntry { + plugin_ref, + handler: handler as Arc, + }; + self.mutate_runtime(|snap| { + snap.route_annotations.insert(key, entry); + }); + } + + /// Remove a route annotation for a specific hook. No-op when no + /// annotation exists for the key. Bumps the generation so downstream + /// caches invalidate. + pub fn remove_route_annotation( + &self, + entity_type: &str, + entity_name: &str, + scope: Option<&str>, + hook_name: &str, + ) { + let key = AnnotationKey { + entity_type: entity_type.to_string(), + entity_name: entity_name.to_string(), + scope: scope.map(str::to_string), + hook_name: hook_name.to_string(), + }; + self.mutate_runtime(|snap| { + snap.route_annotations.remove(&key); + }); + } + // ----------------------------------------------------------------------- // Route Filtering // ----------------------------------------------------------------------- @@ -916,6 +1304,45 @@ impl PluginManager { extensions: &Extensions, hook_name: &str, ) -> Arc> { + // Route annotation short-circuit: if the request's + // (entity_type, entity_name) has an annotation that handles this + // hook, return a one-entry list containing the annotated handler. + // External orchestrators (APL via apl-cpex; future Rego/Cedar) + // register annotations to drive plugin dispatch under their own + // semantics instead of cpex-core's imperative chain. Underlying + // `plugins:` entries stay in the registry for the orchestrator + // to dispatch into by-name via `invoke_entries`. + if !snapshot.route_annotations.is_empty() { + if let Some(meta) = &extensions.meta { + if let (Some(et), Some(en)) = (&meta.entity_type, &meta.entity_name) { + // Scoped lookup first (specific wins); unscoped lookup + // falls back as a "global default" — matches the + // specificity tiebreaker `find_matching_route` uses. + // Lookup is keyed on the hook name as well, so a route + // can install distinct handlers per phase. + let scoped = meta.scope.as_ref().and_then(|s| { + snapshot.route_annotations.get(&AnnotationKey { + entity_type: et.clone(), + entity_name: en.clone(), + scope: Some(s.clone()), + hook_name: hook_name.to_string(), + }) + }); + let candidate = scoped.or_else(|| { + snapshot.route_annotations.get(&AnnotationKey { + entity_type: et.clone(), + entity_name: en.clone(), + scope: None, + hook_name: hook_name.to_string(), + }) + }); + if let Some(entry) = candidate { + return Arc::new(vec![entry.clone()]); + } + } + } + } + // Routing disabled (or no config): fall back to per-plugin // condition filtering. Empty conditions Vec means "fire always", // so this is backward-compatible with configs that don't use @@ -976,14 +1403,29 @@ impl PluginManager { } } - // Slow path: resolve, filter, and cache (allocations only here) - let resolved = config::resolve_plugins_for_entity( - cpex_config, - entity_type, - entity_name, - request_scope, - &meta.tags, - ); + // Slow path: resolve, filter, and cache (allocations only here). + // + // Hook-specific resolution for identity.resolve: the route's + // `identity:` block is the authoritative dispatch list (NOT + // the `plugins:` block, which in APL-driven routes means + // "per-route overrides" rather than "binding"). For every + // other hook, the generic plugins-block resolution applies. + let resolved = if hook_name == crate::identity::HOOK_IDENTITY_RESOLVE { + config::resolve_identity_plugins_for_route( + cpex_config, + entity_type, + entity_name, + request_scope, + ) + } else { + config::resolve_plugins_for_entity( + cpex_config, + entity_type, + entity_name, + request_scope, + &meta.tags, + ) + }; // Filter entries to resolved plugins, preserving resolution order. // If a plugin has config overrides and we have a factory for its kind, @@ -1045,6 +1487,173 @@ impl PluginManager { cached } + /// Build per-hook `HookEntry`s for a plugin with optional route- + /// level overrides. Used by external orchestrators (notably + /// apl-cpex's dispatch plan) that need to splice per-route plugin + /// variants — different `config`, narrower `capabilities`, different + /// `on_error` — into the dispatch lineup while keeping cpex-core + /// the source of truth for instantiation and isolation. + /// + /// Behavior: + /// - **All three overrides `None`:** returns the base entries + /// unchanged. Caller can use them as-is. + /// - **Only `capabilities_override` / `on_error_override` set + /// (`config_override` is `None`):** builds new `PluginRef`s + /// sharing the *base plugin `Arc`* with a merged `TrustedConfig` + /// (override caps / on_error replace base values) and an + /// independent circuit breaker. Cheap — no factory call. + /// - **`config_override` set:** invokes the registered factory for + /// the plugin's `kind` with a merged `PluginConfig` (override + /// `config` *replaces* base `config` wholesale per unified-config + /// spec — not deep merge), calls `initialize()` on the new + /// instance, and wraps every returned handler in a new + /// `PluginRef` with a fresh circuit breaker. + /// + /// Returns an empty `Vec` when: + /// - the plugin name isn't registered in the manager, + /// - the factory for the plugin's `kind` is missing, + /// - the factory's `create` errors, + /// - or `initialize()` fails on the new instance. + /// + /// Each of those is a configuration / wiring fault the caller + /// should treat as `NotFound` at dispatch time. The method logs + /// the underlying error before returning empty so debugging + /// surfaces in operator logs rather than as a silent miss. + pub async fn build_override_entries( + &self, + plugin_name: &str, + config_override: Option<&serde_yaml::Value>, + capabilities_override: Option<&std::collections::HashSet>, + on_error_override: Option, + ) -> Vec<(String, crate::registry::HookEntry)> { + let base_entries = self.find_plugin_entries(plugin_name); + if base_entries.is_empty() { + return Vec::new(); + } + + // No overrides at all — caller can use base entries unchanged. + if config_override.is_none() + && capabilities_override.is_none() + && on_error_override.is_none() + { + return base_entries; + } + + // Pull the base trusted_config off any of the base entries — + // all of them share the same `Arc` for a given + // plugin name, so picking the first is fine. + let base_ref = Arc::clone(&base_entries[0].1.plugin_ref); + let mut merged_config = base_ref.trusted_config().clone(); + + // Capabilities: override replaces base when present. + if let Some(caps) = capabilities_override { + merged_config.capabilities = caps.clone(); + } + + // on_error: override replaces base when present. + if let Some(oe) = on_error_override { + merged_config.on_error = oe; + } + + // Caps/on_error-only path — shared base plugin Arc, new + // PluginRef with merged config + fresh circuit breaker. + // No factory call, no async work. + if config_override.is_none() { + let new_ref = Arc::new(crate::registry::PluginRef::new( + Arc::clone(base_ref.plugin()), + merged_config, + )); + return base_entries + .into_iter() + .map(|(hook_name, base_entry)| { + ( + hook_name, + crate::registry::HookEntry { + plugin_ref: Arc::clone(&new_ref), + handler: base_entry.handler, + }, + ) + }) + .collect(); + } + + // Config override present — factory path. Convert YAML + // override value into the JSON shape `PluginConfig.config` + // carries (YAML is a superset of JSON so serde re-serialization + // is safe). Per spec, override `config` replaces the base + // `config` wholesale. + let cfg_yaml = config_override.expect("checked above"); + let cfg_json = match serde_json::to_value(cfg_yaml) { + Ok(v) => v, + Err(e) => { + error!( + plugin = %plugin_name, + error = %e, + "build_override_entries: YAML→JSON config conversion failed", + ); + return Vec::new(); + } + }; + merged_config.config = Some(cfg_json); + + let kind = merged_config.kind.clone(); + let instance = { + let factories = self.factories.read().unwrap_or_else(|p| p.into_inner()); + let factory = match factories.get(&kind) { + Some(f) => f, + None => { + error!( + plugin = %plugin_name, + kind = %kind, + "build_override_entries: no factory registered for kind", + ); + return Vec::new(); + } + }; + match factory.create(&merged_config) { + Ok(i) => i, + Err(e) => { + error!( + plugin = %plugin_name, + error = %e, + "build_override_entries: factory.create failed", + ); + return Vec::new(); + } + } + }; + + if let Err(e) = instance.plugin.initialize().await { + error!( + plugin = %plugin_name, + error = %e, + "build_override_entries: initialize() failed on new instance", + ); + return Vec::new(); + } + + // One PluginRef shared across the new instance's handlers — + // all hooks served by one instance share a circuit breaker + // (matches registration semantics). + let new_ref = Arc::new(crate::registry::PluginRef::new( + Arc::clone(&instance.plugin), + merged_config, + )); + instance + .handlers + .into_iter() + .map(|(hook_name, handler)| { + ( + hook_name.to_string(), + crate::registry::HookEntry { + plugin_ref: Arc::clone(&new_ref), + handler, + }, + ) + }) + .collect() + } + /// Create an override plugin instance with merged config. /// /// When a route overrides a plugin's config, we create a new @@ -1184,11 +1793,25 @@ impl PluginManager { // Query Methods // ----------------------------------------------------------------------- - /// Whether any plugins are registered for the given hook name. + /// Whether anything would run for the given hook name — either a + /// registered plugin handler OR a route annotation targeting that hook. + /// + /// Route annotations (installed by APL from a route's `policy:` / + /// `args:` / `result:` blocks) must be counted here: a route whose only + /// handler for a phase is an annotation (e.g. a response-side + /// `result: { ssn: redact(...) }` on `cmf.tool_post_invoke`, with no + /// globally-registered post-invoke plugin) would otherwise report + /// "no hooks" and be skipped by out-of-process hosts that use this as a + /// fast-skip gate — silently dropping the route's policy for that phase. pub fn has_hooks_for(&self, hook_name: &str) -> bool { - self.load_runtime() + let snapshot = self.load_runtime(); + snapshot .registry .has_hooks_for(&HookType::new(hook_name)) + || snapshot + .route_annotations + .keys() + .any(|k| k.hook_name.as_str() == hook_name) } /// Look up a plugin by name. Returns an `Arc` clone — works diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index 0b4990c1..30f68bf3 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -459,6 +459,24 @@ impl PluginRegistry { pub fn plugin_names(&self) -> Vec { self.plugins.keys().cloned().collect() } + + /// Returns every (hook_name, HookEntry) pair where the entry's plugin + /// matches the given name. Used by external orchestrators that need + /// to build pre-resolved dispatch lineups for a single plugin across + /// every hook it registered to (e.g. apl-cpex deciding which entry + /// handles step-style invocations vs field-style invocations for the + /// same plugin). Owned tuples — no borrows held on the registry. + pub fn entries_for_plugin(&self, plugin_name: &str) -> Vec<(String, HookEntry)> { + let mut out = Vec::new(); + for (hook_type, entries) in &self.hook_index { + for entry in entries { + if entry.plugin_ref.name() == plugin_name { + out.push((hook_type.as_str().to_string(), entry.clone())); + } + } + } + out + } } impl Default for PluginRegistry { diff --git a/crates/cpex-core/src/visitor.rs b/crates/cpex-core/src/visitor.rs new file mode 100644 index 00000000..98651cfd --- /dev/null +++ b/crates/cpex-core/src/visitor.rs @@ -0,0 +1,134 @@ +// Location: ./crates/cpex-core/src/visitor.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `ConfigVisitor` — extension point for external orchestrators (APL, +// future Rego/Cedar-direct/custom) to participate in unified-config +// loading without cpex-core taking a dep on any specific orchestrator. +// +// # How it fits +// +// The host calls `PluginManager::load_config_yaml(yaml)`. cpex-core +// parses the YAML twice (once into a typed `CpexConfig`, once into a +// raw `serde_yaml::Value`), runs its own plugin instantiation, then +// walks each registered visitor in registration order: +// +// 1. `visit_plugins` — once per visitor, immediately after +// cpex-core's own plugin instantiation, +// receiving the parsed `&[PluginConfig]` +// so the visitor doesn't have to re-parse +// the root `plugins:` block from raw YAML. +// 2. `visit_global` — global config block +// 3. `visit_default` — once per entity_type with a default +// 4. `visit_policy_bundle` — once per named policy group (tag) +// 5. `visit_route` — once per route +// +// Each visitor sees the **raw YAML** so it can find its own block +// (e.g. `apl:`) under any section without cpex-core having to know +// about it. Parsed sibling data is passed alongside (`RouteEntry` for +// routes) for convenience — e.g. APL needs to know whether a route +// matches `tool:` or `resource:` to build the annotation key. +// +// # Why visit per-section rather than per-whole-config +// +// Visitors typically accumulate state across the hierarchy (e.g. APL's +// visitor compiles globals/defaults/tag-bundles into `CompiledRoute`s +// kept in visitor state, then merges them into each route at +// `visit_route`). Per-section calls give the orchestrator a natural +// place to do that accumulation without re-parsing. +// +// # Visit order +// +// All sections for one visitor run before the next visitor starts. For +// single-visitor deployments (the common case) this is identical to +// any other ordering; for multi-visitor it gives each visitor a +// consistent view of its own internal state. Visitor methods are +// invoked synchronously — no async runtime needed at load time. + +use std::sync::Arc; + +use crate::config::RouteEntry; +use crate::manager::PluginManager; +use crate::plugin::PluginConfig; + +/// Error type returned by a config visitor. Boxed `dyn Error` so each +/// orchestrator can carry its own error variants (parse errors, missing +/// plugin references, etc.) without cpex-core having to enumerate them. +pub type VisitorError = Box; + +/// Extension point for external orchestrators to participate in unified +/// config loading. Register via [`PluginManager::register_visitor`]; +/// invoked during [`PluginManager::load_config_yaml`]. +/// +/// All methods have default no-op implementations — a visitor only +/// overrides the sections it cares about. +pub trait ConfigVisitor: Send + Sync { + /// Stable identifier for diagnostics — included in error contexts + /// if a visitor method returns Err. Convention: short kebab-case + /// matching the orchestrator's YAML key (e.g. `"apl"`, `"rego"`). + fn name(&self) -> &str; + + /// Visit the typed plugin declarations from the root `plugins:` + /// block. Called once per visitor, immediately after cpex-core's + /// own plugin instantiation completes and before any hierarchy + /// section is walked. Visitors that need a per-name registry of + /// hook / capability / on_error metadata can populate it here + /// without re-parsing the YAML — cpex-core has already validated + /// the block (no duplicate names, etc.) by this point. + fn visit_plugins( + &self, + _mgr: &Arc, + _plugins: &[PluginConfig], + ) -> Result<(), VisitorError> { + Ok(()) + } + + /// Visit the top-level `global:` block. `yaml` is the raw value at + /// that path, or `Value::Null` if `global:` is absent. + fn visit_global( + &self, + _mgr: &Arc, + _yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + Ok(()) + } + + /// Visit one entry in `global.defaults`. Called once per + /// `(entity_type, default_block)` pair. `yaml` is the raw value at + /// `global.defaults.`. + fn visit_default( + &self, + _mgr: &Arc, + _entity_type: &str, + _yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + Ok(()) + } + + /// Visit one entry in `global.policies` (a named tag bundle). + /// Called once per `(tag, policy_group)` pair. `yaml` is the raw + /// value at `global.policies.`. + fn visit_policy_bundle( + &self, + _mgr: &Arc, + _tag: &str, + _yaml: &serde_yaml::Value, + ) -> Result<(), VisitorError> { + Ok(()) + } + + /// Visit one route entry. `yaml` is the raw value at `routes[i]` + /// (so orchestrator can find its own block like `apl:`); `parsed` + /// is the typed `RouteEntry` cpex-core deserialized (so the + /// orchestrator can read `tool`/`resource`/`prompt`/`llm`, + /// `meta.scope`, `meta.tags`, etc. without re-parsing). + fn visit_route( + &self, + _mgr: &Arc, + _yaml: &serde_yaml::Value, + _parsed: &RouteEntry, + ) -> Result<(), VisitorError> { + Ok(()) + } +} diff --git a/crates/cpex-core/tests/delegation_e2e.rs b/crates/cpex-core/tests/delegation_e2e.rs new file mode 100644 index 00000000..10ff13b9 --- /dev/null +++ b/crates/cpex-core/tests/delegation_e2e.rs @@ -0,0 +1,722 @@ +// Location: ./crates/cpex-core/tests/delegation_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for the TokenDelegate hook family — sub-step B of +// slice 3. +// +// Verifies the host-explicit dispatch model: an outbound caller +// (typically a forwarding-proxy plugin) constructs a +// `DelegationPayload`, calls +// `mgr.invoke_named::(...)`, and reads the +// minted credential out of the returned `PipelineResult`. No +// bespoke method on `PluginManager` — `invoke_named` works +// uniformly because Sequential-phase threading already does the +// right thing for the unified `DelegationPayload`. +// +// Tests cover: +// - Single-handler mint: one plugin produces a `RawDelegatedToken`. +// - Two-handler chain: handler A declines (`delegated_token == None`), +// handler B mints — proves Sequential-phase threading carries +// A's null contribution into B's input. +// - Rejection: handler returns `deny()`; pipeline halts. +// - `from_pipeline_result` returns `None` on deny. +// - Full host flow: invoke delegate, apply to Extensions, observe +// `Extensions.raw_credentials.delegated_tokens` populated under +// the synthesized `DelegationKey`. + +use std::sync::Arc; + +use async_trait::async_trait; + +use chrono::{Duration as ChronoDuration, Utc}; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, + HOOK_TOKEN_DELEGATE, +}; +use cpex_core::error::PluginError; +use cpex_core::extensions::raw_credentials::{DelegationKey, DelegationMode, RawDelegatedToken}; +use cpex_core::extensions::{SecurityExtension, SubjectExtension}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +// ===================================================================== +// Plugin fixtures +// ===================================================================== + +/// Minimal RFC-8693-style stub. Doesn't actually exchange anything +/// — just constructs a `RawDelegatedToken` by combining the caller's +/// bearer token with the target audience. Real handlers would call +/// out to an IdP; we only care about wiring here. +struct StubExchanger { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for StubExchanger { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for StubExchanger { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + assert!( + !payload.bearer_token().is_empty(), + "exchanger expected non-empty bearer token", + ); + + // Use the route's TTL hint if present; otherwise default to + // 300s. Real handlers would also take min(route_hint, + // idp_response_expires_in). + let ttl_secs = payload + .route_attenuation() + .and_then(|a| a.ttl_seconds) + .unwrap_or(300); + let audience = payload + .target_audience() + .unwrap_or("https://example.com/default") + .to_string(); + // Effective scopes: combine route-attenuation capabilities + // with required_permissions. Real exchangers may narrow + // further based on the IdP's response. + let mut scopes = payload.required_permissions().to_vec(); + if let Some(att) = payload.route_attenuation() { + for cap in &att.capabilities { + if !scopes.contains(cap) { + scopes.push(cap.clone()); + } + } + } + + let minted = RawDelegatedToken::new( + format!("stub-exchanged({})", payload.bearer_token()), + "Authorization", + audience, + scopes, + Utc::now() + ChronoDuration::seconds(ttl_secs as i64), + ); + let mut updated = payload.clone(); + updated.delegated_token = Some(minted); + updated.minted_at = Some(Utc::now()); + PluginResult::modify_payload(updated) + } +} + +/// A handler that always declines — leaves `delegated_token` as +/// `None`. Used to verify chaining: in a chain with a declining +/// primary + a minting fallback, the fallback should see the +/// declined state and mint. +struct DecliningHandler { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DecliningHandler { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DecliningHandler { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Returns the payload unchanged — leaves output slots None, + // signals "this handler had nothing to contribute." + let mut updated = payload.clone(); + updated.metadata.insert( + "declined_by".into(), + serde_json::json!("declining-handler"), + ); + PluginResult::modify_payload(updated) + } +} + +/// Fallback minter — runs after a declining handler. Asserts that +/// the prior handler's `metadata` contribution survived through +/// Sequential-phase threading (i.e. we see "declined_by") and +/// produces a token in spite of the prior decline. +struct FallbackMinter { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for FallbackMinter { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for FallbackMinter { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + assert!( + payload.delegated_token.is_none(), + "fallback minter expected no prior token in chain test", + ); + assert!( + payload.metadata.contains_key("declined_by"), + "fallback minter expected prior handler's metadata in chain", + ); + let mut updated = payload.clone(); + updated.delegated_token = Some(RawDelegatedToken::new( + "fallback-token", + "Authorization", + payload + .target_audience() + .unwrap_or("https://fallback.example.com") + .to_string(), + vec!["read".into()], + Utc::now() + ChronoDuration::seconds(60), + )); + PluginResult::modify_payload(updated) + } +} + +/// Handler that rejects unconditionally. Used to verify the +/// rejection path through `PluginResult::deny`. +struct RejectingHandler { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for RejectingHandler { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for RejectingHandler { + async fn handle( + &self, + _payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(cpex_core::error::PluginViolation::new( + "delegation.scope_too_broad", + "requested scopes exceed inbound credential's authorization", + )) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn config(name: &str, priority: i32) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec![HOOK_TOKEN_DELEGATE.to_string()], + mode: PluginMode::Sequential, + priority, + on_error: OnError::Fail, + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } +} + +/// Build the kind of payload a forwarding-proxy plugin would construct +/// just before making a downstream call. +fn build_payload(target: &str, audience: &str, permissions: &[&str]) -> DelegationPayload { + DelegationPayload::new("eyJ.caller.tok", target) + .with_target_type(TargetType::Tool) + .with_target_audience(audience) + .with_required_permissions(permissions.iter().map(|s| s.to_string()).collect()) + .with_auth_enforced_by(AuthEnforcedBy::Target) + .with_route_attenuation(AttenuationConfig { + capabilities: vec!["audit".into()], + resource_template: Some("hr://employees/{{ args.id }}".into()), + actions: vec!["read".into()], + ttl_seconds: Some(120), + }) +} + +fn extract_delegation(result: &cpex_core::executor::PipelineResult) -> DelegationPayload { + DelegationPayload::from_pipeline_result(result) + .expect("PipelineResult had no DelegationPayload — denied or wrong hook type") +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Single handler runs, mints a `RawDelegatedToken`. Host receives +/// the populated payload via `from_pipeline_result`. +#[tokio::test] +async fn single_handler_mints_token() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("stub-exchanger", 10); + let plugin = Arc::new(StubExchanger { + cfg: cfg.clone(), + }); + mgr.register_handler_for_names::( + plugin, + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ), + Extensions::default(), + None, + ) + .await; + assert!(result.continue_processing); + + let final_payload = extract_delegation(&result); + let token = final_payload + .delegated_token + .as_ref() + .expect("handler should have minted a token"); + + assert_eq!(token.audience, "https://hr.example.com"); + assert_eq!(token.outbound_header, "Authorization"); + assert!(token.scopes.contains(&"read:compensation".to_string())); + // Route attenuation contributed `audit` capability. + assert!(token.scopes.contains(&"audit".to_string())); + // TTL respects the route hint (120s) — token must expire in + // roughly 120s, not 300s default. + let ttl_left = (token.expires_at - Utc::now()).num_seconds(); + assert!( + ttl_left <= 120 && ttl_left > 100, + "token TTL should reflect route hint (~120s); got {}s", + ttl_left, + ); + // Input fields preserved through clone. + assert_eq!(final_payload.bearer_token(), "eyJ.caller.tok"); + assert_eq!(final_payload.target_name(), "get_compensation"); +} + +/// Two-handler chain: declining primary + minting fallback. Proves +/// Sequential-phase threading carries the declining handler's +/// metadata contribution into the fallback handler, and that the +/// fallback's output replaces the lack of a token from the primary. +#[tokio::test] +async fn declining_then_fallback_chain_mints_token() { + let mgr = Arc::new(PluginManager::default()); + + let declining_cfg = config("declining-handler", 10); + let declining = Arc::new(DecliningHandler { + cfg: declining_cfg.clone(), + }); + mgr.register_handler_for_names::( + declining, + declining_cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + + let fallback_cfg = config("fallback-minter", 20); + let fallback = Arc::new(FallbackMinter { + cfg: fallback_cfg.clone(), + }); + mgr.register_handler_for_names::( + fallback, + fallback_cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload( + "downstream-tool", + "https://downstream.example.com", + &["read"], + ), + Extensions::default(), + None, + ) + .await; + assert!(result.continue_processing); + + let final_payload = extract_delegation(&result); + // Fallback minted a token. + let token = final_payload + .delegated_token + .as_ref() + .expect("fallback should have minted"); + assert_eq!(&*token.token, "fallback-token"); + // Declining handler's metadata survived. + assert_eq!( + final_payload.metadata.get("declined_by"), + Some(&serde_json::json!("declining-handler")), + ); +} + +/// Rejecting handler short-circuits via `PluginResult::deny`. Pipeline +/// halts; violation surfaces in `PipelineResult.violation`. +#[tokio::test] +async fn rejecting_handler_halts_pipeline() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("rejecting-handler", 10); + let plugin = Arc::new(RejectingHandler { + cfg: cfg.clone(), + }); + mgr.register_handler_for_names::( + plugin, + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload("tool", "https://aud.example.com", &["read"]), + Extensions::default(), + None, + ) + .await; + assert!(!result.continue_processing); + // from_pipeline_result returns None on deny — host's signal that + // no token was minted. + assert!(DelegationPayload::from_pipeline_result(&result).is_none()); + let violation = result.violation.expect("rejection should surface"); + assert_eq!(violation.code, "delegation.scope_too_broad"); +} + +/// Full host-side flow: a request already has a resolved subject in +/// `Extensions.security.subject` (from a prior IdentityResolve pass); +/// the outbound forwarding plugin invokes TokenDelegate; the host +/// applies the result back to Extensions; the minted token now lives +/// in `Extensions.raw_credentials.delegated_tokens` keyed by a +/// `DelegationKey` that incorporates the subject id. +#[tokio::test] +async fn apply_to_extensions_writes_delegated_token_keyed_by_subject() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("stub-exchanger", 10); + let plugin = Arc::new(StubExchanger { + cfg: cfg.clone(), + }); + mgr.register_handler_for_names::( + plugin, + cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + mgr.initialize().await.unwrap(); + + // Initial extensions: identity has already populated subject. + let initial_ext = Extensions { + security: Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ), + initial_ext.clone(), + None, + ) + .await; + assert!(result.continue_processing); + + let delegation = extract_delegation(&result); + let updated_ext = delegation.apply_to_extensions(initial_ext); + + // Minted token now lives in Extensions.raw_credentials.delegated_tokens. + let raw = updated_ext + .raw_credentials + .as_ref() + .expect("raw_credentials slot populated"); + assert_eq!(raw.delegated_tokens.len(), 1); + + // The key is synthesized from (subject.id, audience, scopes, mode). + let expected_key = DelegationKey { + subject_id: "alice@corp.com".into(), + audience: "https://hr.example.com".into(), + // Order matches what StubExchanger produces (required_permissions + // first, then attenuation capabilities). + scopes: vec!["read:compensation".into(), "audit".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + assert!( + raw.delegated_tokens.contains_key(&expected_key), + "delegated_tokens missing expected key; saw keys: {:?}", + raw.delegated_tokens.keys().collect::>(), + ); + + // Subject from the prior identity pass survived apply. + let sec = updated_ext.security.as_ref().unwrap(); + assert_eq!( + sec.subject.as_ref().unwrap().id.as_deref(), + Some("alice@corp.com"), + ); +} + +/// Load-bearing integration test: the full host flow from token +/// delegation through downstream CMF dispatch correctly cap-gates +/// the `delegated_tokens` slot. +/// +/// Mirrors the slice 2 `cap_gating_post_apply_through_cmf_dispatch` +/// test but for the *outbound* leg: +/// 1. TokenDelegate handler mints a downstream credential. +/// 2. Host applies the resolved payload back to `Extensions` via +/// `apply_to_extensions` — the minted token lands in +/// `Extensions.raw_credentials.delegated_tokens`. +/// 3. Host invokes `cmf.tool_pre_invoke` (the next outbound step, +/// typically where a forwarding proxy attaches the credential). +/// Two registered CMF plugins: +/// - `DelegatedTokenReader` declares `read_delegated_tokens` +/// — must observe one minted token. +/// - `DelegatedTokenBlind` declares no credential capability +/// — must observe `raw_credentials == None` because +/// `filter_extensions` strips the slot. +/// +/// Validates the symmetric story to identity's `read_inbound_credentials` +/// gating: only forwarding plugins (audit-trail consumers, proxies) +/// that explicitly declare the cap can see the minted credentials. +#[tokio::test] +async fn cap_gating_post_apply_through_cmf_dispatch() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use cpex_core::cmf::enums::Role; + use cpex_core::cmf::{CmfHook, Message, MessagePayload}; + + // ----- CMF plugin WITH read_delegated_tokens ----- + struct DelegatedTokenReader { + cfg: PluginConfig, + saw_token_count: Arc, + } + #[async_trait] + impl Plugin for DelegatedTokenReader { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for DelegatedTokenReader { + async fn handle( + &self, + _payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let n = ext + .raw_credentials + .as_ref() + .map(|r| r.delegated_tokens.len()) + .unwrap_or(0); + self.saw_token_count.store(n, Ordering::SeqCst); + PluginResult::allow() + } + } + + // ----- CMF plugin WITHOUT credential caps ----- + struct DelegatedTokenBlind { + cfg: PluginConfig, + saw_any: Arc, + } + #[async_trait] + impl Plugin for DelegatedTokenBlind { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for DelegatedTokenBlind { + async fn handle( + &self, + _payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + self.saw_any + .store(ext.raw_credentials.is_some(), Ordering::SeqCst); + PluginResult::allow() + } + } + + // ----- Wire everything up ----- + let mgr = Arc::new(PluginManager::default()); + + // TokenDelegate handler. + let td_cfg = config("stub-exchanger", 10); + let td_plugin = Arc::new(StubExchanger { + cfg: td_cfg.clone(), + }); + mgr.register_handler_for_names::( + td_plugin, + td_cfg, + &[HOOK_TOKEN_DELEGATE], + ) + .unwrap(); + + // CMF reader — declares read_delegated_tokens. Also declares + // read_subject so the handler can verify subject still visible + // through the request lifecycle. + let reader_saw_count = Arc::new(AtomicUsize::new(usize::MAX)); + let reader_cfg = PluginConfig { + name: "delegated-reader".into(), + kind: "test".into(), + description: None, + author: None, + version: None, + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: ["read_delegated_tokens", "read_subject"] + .iter() + .map(|s| s.to_string()) + .collect(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + }; + mgr.register_handler_for_names::( + Arc::new(DelegatedTokenReader { + cfg: reader_cfg.clone(), + saw_token_count: Arc::clone(&reader_saw_count), + }), + reader_cfg, + &["cmf.tool_pre_invoke"], + ) + .unwrap(); + + // CMF blind — no cred caps. + let blind_saw = Arc::new(AtomicBool::new(false)); + let blind_cfg = PluginConfig { + name: "delegated-blind".into(), + kind: "test".into(), + description: None, + author: None, + version: None, + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 20, + on_error: OnError::Fail, + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + }; + mgr.register_handler_for_names::( + Arc::new(DelegatedTokenBlind { + cfg: blind_cfg.clone(), + saw_any: Arc::clone(&blind_saw), + }), + blind_cfg, + &["cmf.tool_pre_invoke"], + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // ----- Host flow ----- + // 1. Initial Extensions has a subject (typically from a prior + // IdentityResolve pass). + let initial_ext = Extensions { + security: Some(Arc::new(SecurityExtension { + subject: Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + + // 2. Token delegation. + let (td_result, _bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ), + initial_ext.clone(), + None, + ) + .await; + assert!(td_result.continue_processing); + let delegation = DelegationPayload::from_pipeline_result(&td_result) + .expect("delegation should have minted"); + + // 3. Apply. + let updated_ext = delegation.apply_to_extensions(initial_ext); + + // 4. Dispatch through CMF. + let cmf_payload = MessagePayload { + message: Message::text(Role::User, "fetch compensation"), + }; + let (cmf_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload, + updated_ext, + None, + ) + .await; + assert!( + cmf_result.continue_processing, + "CMF dispatch should not be blocked: violation = {:?}", + cmf_result.violation, + ); + + // ----- Verifications ----- + // Plugin with cap saw the minted token. + assert_eq!( + reader_saw_count.load(Ordering::SeqCst), + 1, + "DelegatedTokenReader with read_delegated_tokens should see 1 token", + ); + // Plugin without cap saw no raw_credentials at all. + assert!( + !blind_saw.load(Ordering::SeqCst), + "DelegatedTokenBlind without credential caps must NOT see raw_credentials", + ); +} + +// PluginError kept imported so a future test wanting to assert on a +// specific error variant can use it without an extra `use` line. +#[allow(dead_code)] +fn _force_plugin_error_link(_e: PluginError) {} diff --git a/crates/cpex-core/tests/identity_e2e.rs b/crates/cpex-core/tests/identity_e2e.rs new file mode 100644 index 00000000..d262a1b2 --- /dev/null +++ b/crates/cpex-core/tests/identity_e2e.rs @@ -0,0 +1,744 @@ +// Location: ./crates/cpex-core/tests/identity_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for the IdentityResolve hook family — sub-step B +// of slice 2. +// +// Verifies the host-explicit dispatch model: the host constructs an +// `IdentityPayload`, calls `mgr.invoke_named::(...)`, +// and reads the populated identity slots back out of the returned +// `PipelineResult.modified_payload`. No bespoke `resolve_identity` +// method on `PluginManager` — `invoke_named` works for `IdentityHook` +// like every other hook, because Sequential-phase threading already +// does the right thing for the unified `IdentityPayload` +// (input + accumulator in one struct). +// +// Tests cover: +// - Single-handler resolve: one plugin populates `subject`. +// - Two-handler chain: plugin A populates `subject`, plugin B +// receives A's output and populates `caller_workload`. Final +// payload carries both — proves Sequential-phase threading. +// - In-band rejection: a handler sets `rejected = true`; the +// pipeline halts; status + reason flow back to the caller. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::{SubjectExtension, WorkloadIdentity}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +// ===================================================================== +// Plugin fixtures +// ===================================================================== + +/// A fake JWT resolver. Doesn't actually validate anything — just +/// asserts a non-empty `raw_token()` and writes a hard-coded subject. +/// Real resolvers would parse + validate the token; for wiring tests +/// we only care that the handler receives the right payload shape +/// and that its output flows back through Sequential-phase threading. +struct SubjectResolver { + cfg: PluginConfig, + subject_id: String, +} + +#[async_trait] +impl Plugin for SubjectResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for SubjectResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + assert!( + !payload.raw_token().is_empty(), + "subject resolver expected a non-empty token", + ); + let mut updated = payload.clone(); + updated.subject = Some(SubjectExtension { + id: Some(self.subject_id.clone()), + ..Default::default() + }); + PluginResult::modify_payload(updated) + } +} + +/// Workload resolver. Pulls a SPIFFE-ID out of (in real life) +/// `X-Forwarded-Client-Cert`; here we read it from the +/// `IdentityPayload.headers()` map and hand-roll a `WorkloadIdentity`. +/// Critical assertion for the chaining test: when this runs *after* +/// `SubjectResolver`, it must see `payload.subject` already populated +/// — proves Sequential-phase threading carries plugin 1's output +/// forward into plugin 2's input. +struct WorkloadResolver { + cfg: PluginConfig, + require_prior_subject: bool, +} + +#[async_trait] +impl Plugin for WorkloadResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for WorkloadResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if self.require_prior_subject { + assert!( + payload.subject.is_some(), + "workload resolver expected prior subject in chained run", + ); + } + let spiffe_id = payload + .headers() + .get("x-spiffe-id") + .cloned() + .unwrap_or_else(|| "spiffe://example.com/unknown".to_string()); + let mut updated = payload.clone(); + updated.caller_workload = Some(WorkloadIdentity { + spiffe_id: Some(spiffe_id), + trust_domain: Some("example.com".to_string()), + ..Default::default() + }); + PluginResult::modify_payload(updated) + } +} + +/// Handler that always rejects. Used to verify the in-band rejection +/// pathway: setting `rejected = true` on the returned payload (and +/// using `PluginResult::deny`) must halt the pipeline. +struct RejectingResolver { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for RejectingResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for RejectingResolver { + async fn handle( + &self, + _payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(cpex_core::error::PluginViolation::new( + "auth.expired", + "token expired", + )) + } +} + +// ===================================================================== +// Helpers +// ===================================================================== + +fn config(name: &str, priority: i32) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec![HOOK_IDENTITY_RESOLVE.to_string()], + mode: PluginMode::Sequential, + priority, + on_error: OnError::Fail, + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } +} + +/// Build the payload the way a host normally would: raw token from +/// `Authorization`, headers preserved, source set. Identity handlers +/// downstream read these via the public accessors. +fn build_payload(token: &str) -> IdentityPayload { + let mut headers = std::collections::HashMap::new(); + headers.insert( + "authorization".to_string(), + format!("Bearer {}", token), + ); + headers.insert( + "x-spiffe-id".to_string(), + "spiffe://example.com/agent-1".to_string(), + ); + IdentityPayload::new(token, TokenSource::Bearer) + .with_source_header("Authorization") + .with_headers(headers) +} + +/// Shortcut around `IdentityPayload::from_pipeline_result` for tests +/// that know the result must be present and well-typed. +fn extract_identity(result: &cpex_core::executor::PipelineResult) -> IdentityPayload { + IdentityPayload::from_pipeline_result(result) + .expect("PipelineResult had no IdentityPayload — denied or wrong hook type") +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Single handler runs, populates subject. Host receives an +/// `IdentityPayload` with subject populated; input fields survive +/// the chain unchanged. +#[tokio::test] +async fn single_resolver_populates_subject() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("subject-resolver", 10); + let plugin = Arc::new(SubjectResolver { + cfg: cfg.clone(), + subject_id: "alice@corp.com".to_string(), + }); + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + Extensions::default(), + None, + ) + .await; + + assert!(result.continue_processing, "pipeline should allow"); + let final_payload = extract_identity(&result); + + // Output populated by the handler. + assert_eq!( + final_payload.subject.as_ref().unwrap().id.as_deref(), + Some("alice@corp.com"), + ); + + // Input fields preserved through Sequential threading + clone. + assert_eq!(final_payload.raw_token(), "eyJ.fake.jwt"); + assert_eq!(final_payload.source_header(), Some("Authorization")); +} + +/// Two handlers in priority order. Handler 1 writes subject; handler +/// 2 — running after — must see subject already populated (via the +/// `require_prior_subject` assertion in its handler). Final payload +/// carries both contributions. +/// +/// This is the load-bearing test for the whole design: it proves +/// that Sequential-phase threading is exactly what the multi-handler +/// composition model needs, without any framework changes beyond +/// what already exists for CMF. +#[tokio::test] +async fn two_resolvers_chain_populates_both_slots() { + let mgr = Arc::new(PluginManager::default()); + + let subject_cfg = config("subject-resolver", 10); + let subject = Arc::new(SubjectResolver { + cfg: subject_cfg.clone(), + subject_id: "alice@corp.com".to_string(), + }); + mgr.register_handler::(subject, subject_cfg) + .unwrap(); + + let workload_cfg = config("workload-resolver", 20); // runs after subject + let workload = Arc::new(WorkloadResolver { + cfg: workload_cfg.clone(), + require_prior_subject: true, + }); + mgr.register_handler::(workload, workload_cfg) + .unwrap(); + + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + Extensions::default(), + None, + ) + .await; + + assert!(result.continue_processing, "pipeline should allow"); + let final_payload = extract_identity(&result); + + // Subject from plugin 1 survived plugin 2's pass. + assert_eq!( + final_payload.subject.as_ref().unwrap().id.as_deref(), + Some("alice@corp.com"), + ); + + // Workload added by plugin 2. + let workload = final_payload + .caller_workload + .as_ref() + .expect("workload resolver should have populated caller_workload"); + assert_eq!( + workload.spiffe_id.as_deref(), + Some("spiffe://example.com/agent-1"), + ); + + // Original input fields still intact. + assert_eq!(final_payload.raw_token(), "eyJ.fake.jwt"); +} + +/// Rejecting handler short-circuits the pipeline. `continue_processing` +/// is `false`; the violation surfaces in `PipelineResult.violation`. +/// Hosts use this to skip downstream tool invocation and return +/// a 401/403 to the client. +#[tokio::test] +async fn rejecting_resolver_halts_pipeline() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("rejecting-resolver", 10); + let plugin = Arc::new(RejectingResolver { cfg: cfg.clone() }); + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.expired.jwt"), + Extensions::default(), + None, + ) + .await; + + assert!(!result.continue_processing, "rejection should halt"); + let violation = result.violation.expect("rejected → violation present"); + assert_eq!(violation.code, "auth.expired"); + assert_eq!(violation.reason, "token expired"); +} + +/// Full host-side flow: invoke identity, apply the resolved payload +/// back to the `Extensions`, observe that the identity slots are now +/// populated on `Extensions.security.*` / `Extensions.raw_credentials`. +/// Downstream `cmf.tool_pre_invoke` would now see the resolved subject +/// — that's the whole point of having an identity hook. +/// +/// Also exercises the slice-1 invariant that pre-existing security +/// fields (labels, classification) survive the apply step — the +/// host shouldn't lose its earlier annotations just because identity +/// landed. +#[tokio::test] +async fn apply_to_extensions_populates_security_and_preserves_existing_fields() { + use cpex_core::extensions::SecurityExtension; + use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, + }; + + // ----- Handler: produces a subject + a RawCredentialsExtension ----- + struct FullResolver { + cfg: PluginConfig, + } + #[async_trait] + impl Plugin for FullResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for FullResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let token_bytes = payload.raw_token().to_string(); + let mut updated = payload.clone(); + updated.subject = Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }); + // Stash the validated token under TokenRole::User so a + // forwarding plugin can re-attach it later. + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new(token_bytes, "Authorization", TokenKind::Jwt), + ); + updated.raw_credentials = Some(raw); + PluginResult::modify_payload(updated) + } + } + + let mgr = Arc::new(PluginManager::default()); + let cfg = config("full-resolver", 10); + let plugin = Arc::new(FullResolver { cfg: cfg.clone() }); + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + // ----- Host's initial Extensions carries a pre-existing label ----- + // We need to verify that applying the identity result doesn't + // clobber the label — identity should only touch identity slots. + let mut initial_security = SecurityExtension::default(); + initial_security.add_label("PII"); + initial_security.classification = Some("internal".into()); + let initial_ext = Extensions { + security: Some(Arc::new(initial_security)), + ..Default::default() + }; + + // ----- Run identity resolution ----- + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + initial_ext.clone(), + None, + ) + .await; + + assert!(result.continue_processing); + + // ----- Apply back to Extensions ----- + let final_payload = extract_identity(&result); + let updated_ext = final_payload.apply_to_extensions(initial_ext); + + // Identity slots populated on security. + let sec = updated_ext.security.as_ref().expect("security slot present"); + assert_eq!( + sec.subject.as_ref().unwrap().id.as_deref(), + Some("alice@corp.com"), + ); + + // Pre-existing fields preserved — this is the load-bearing + // assertion for the merge-not-replace semantics. + assert!(sec.has_label("PII"), "pre-existing label survived apply"); + assert_eq!(sec.classification.as_deref(), Some("internal")); + + // RawCredentials surfaced into Extensions. + let raw = updated_ext + .raw_credentials + .as_ref() + .expect("raw_credentials slot present"); + let user_token = raw + .inbound_tokens + .get(&TokenRole::User) + .expect("user token present"); + assert_eq!(user_token.source_header, "Authorization"); + // Token bytes carried over end-to-end. Note: this only works + // because RawCredentialsExtension lives in-process — out-of-process + // serialization would strip the token field. + assert_eq!(&*user_token.token, "eyJ.fake.jwt"); +} + +/// When the IdentityHook chain is denied, `from_pipeline_result` +/// returns `None` because the executor produces no `modified_payload` +/// on the deny path. Hosts use this to distinguish "identity +/// resolved" from "identity rejected" without a separate type. +#[tokio::test] +async fn from_pipeline_result_returns_none_on_deny() { + let mgr = Arc::new(PluginManager::default()); + let cfg = config("rejecter", 10); + let plugin = Arc::new(RejectingResolver { cfg: cfg.clone() }); + mgr.register_handler::(plugin, cfg).unwrap(); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.tok"), + Extensions::default(), + None, + ) + .await; + assert!(!result.continue_processing); + assert!(IdentityPayload::from_pipeline_result(&result).is_none()); +} + +/// Load-bearing integration test: the full host flow from identity +/// resolution through CMF dispatch correctly cap-gates the +/// `raw_credentials` slot. +/// +/// Scenario: +/// 1. IdentityResolve handler populates `subject` + a +/// RawCredentialsExtension with a User token. +/// 2. Host applies the resolved payload back to `Extensions` via +/// `apply_to_extensions`, getting a fully-populated request +/// Extensions container. +/// 3. Host invokes `cmf.tool_pre_invoke` against two registered +/// CMF plugins: +/// - `InboundReader` declares `read_inbound_credentials` — +/// must observe `raw_credentials` with one token. +/// - `InboundBlind` declares no credential capability — +/// must observe `raw_credentials == None` because the +/// executor's `filter_extensions` strips the slot. +/// +/// Proves end-to-end that cap-gating is honored when the identity +/// hook's output flows through the host's apply-then-dispatch path. +/// The unit tests in `extensions/filter.rs` exercise the gate in +/// isolation; this test pins the wiring through the real executor. +#[tokio::test] +async fn cap_gating_post_apply_through_cmf_dispatch() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Mutex; + + use cpex_core::cmf::enums::Role; + use cpex_core::cmf::{CmfHook, Message, MessagePayload}; + use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole, + }; + use cpex_core::extensions::SecurityExtension; + + // ----- Identity resolver: populates subject + one inbound token ----- + struct FullResolver { + cfg: PluginConfig, + } + #[async_trait] + impl Plugin for FullResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for FullResolver { + async fn handle( + &self, + payload: &IdentityPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let token = payload.raw_token().to_string(); + let mut updated = payload.clone(); + updated.subject = Some(SubjectExtension { + id: Some("alice@corp.com".into()), + ..Default::default() + }); + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new(token, "Authorization", TokenKind::Jwt), + ); + updated.raw_credentials = Some(raw); + PluginResult::modify_payload(updated) + } + } + + // ----- CMF plugin WITH read_inbound_credentials ----- + // Writes 1 if it saw a token, 0 if it saw none. + struct InboundReader { + cfg: PluginConfig, + saw_token_count: Arc, + saw_subject_id: Arc>>, + } + #[async_trait] + impl Plugin for InboundReader { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for InboundReader { + async fn handle( + &self, + _payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Should see the token — plugin declared the cap. + let n = ext + .raw_credentials + .as_ref() + .map(|r| r.inbound_tokens.len()) + .unwrap_or(0); + self.saw_token_count.store(n, Ordering::SeqCst); + // Subject also visible — read_subject gives id+type baseline. + let id = ext + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.clone()); + *self.saw_subject_id.lock().unwrap() = id; + PluginResult::allow() + } + } + + // ----- CMF plugin WITHOUT credential caps ----- + // Records whether it observed raw_credentials (it shouldn't). + struct InboundBlind { + cfg: PluginConfig, + saw_any_credentials: Arc, + } + #[async_trait] + impl Plugin for InboundBlind { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + impl HookHandler for InboundBlind { + async fn handle( + &self, + _payload: &MessagePayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // raw_credentials must be None — filter_extensions strips + // the slot when neither sub-cap is held. + self.saw_any_credentials + .store(ext.raw_credentials.is_some(), Ordering::SeqCst); + PluginResult::allow() + } + } + + // ----- Wire it all up ----- + let mgr = Arc::new(PluginManager::default()); + + // IdentityHook handler. + let id_cfg = config("full-resolver", 10); + mgr.register_handler::( + Arc::new(FullResolver { + cfg: id_cfg.clone(), + }), + id_cfg, + ) + .unwrap(); + + // CMF plugins. Both register against cmf.tool_pre_invoke; they + // run in priority order during the same invoke. + let reader_saw_count = Arc::new(AtomicUsize::new(usize::MAX)); // sentinel + let reader_saw_subject = Arc::new(Mutex::new(None)); + let reader_cfg = PluginConfig { + name: "inbound-reader".into(), + kind: "test".into(), + description: None, + author: None, + version: None, + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: ["read_inbound_credentials", "read_subject"] + .iter() + .map(|s| s.to_string()) + .collect(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + }; + mgr.register_handler_for_names::( + Arc::new(InboundReader { + cfg: reader_cfg.clone(), + saw_token_count: Arc::clone(&reader_saw_count), + saw_subject_id: Arc::clone(&reader_saw_subject), + }), + reader_cfg, + &["cmf.tool_pre_invoke"], + ) + .unwrap(); + + let blind_saw_creds = Arc::new(AtomicBool::new(false)); + let blind_cfg = PluginConfig { + name: "inbound-blind".into(), + kind: "test".into(), + description: None, + author: None, + version: None, + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 20, + on_error: OnError::Fail, + capabilities: Default::default(), // no caps + tags: Vec::new(), + conditions: Vec::new(), + config: None, + }; + mgr.register_handler_for_names::( + Arc::new(InboundBlind { + cfg: blind_cfg.clone(), + saw_any_credentials: Arc::clone(&blind_saw_creds), + }), + blind_cfg, + &["cmf.tool_pre_invoke"], + ) + .unwrap(); + + mgr.initialize().await.unwrap(); + + // ----- Host flow ----- + // 1. Initial Extensions carrying a label — verifies later that + // apply_to_extensions doesn't clobber pre-existing security + // fields when populating identity slots. + let mut initial_security = SecurityExtension::default(); + initial_security.add_label("PII"); + let initial_ext = Extensions { + security: Some(Arc::new(initial_security)), + ..Default::default() + }; + + // 2. Identity resolution. + let (id_result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + initial_ext.clone(), + None, + ) + .await; + assert!(id_result.continue_processing); + let identity = IdentityPayload::from_pipeline_result(&id_result) + .expect("identity should have resolved"); + + // 3. Apply. + let updated_ext = identity.apply_to_extensions(initial_ext); + + // 4. Dispatch through CMF. Both plugins run; each sees the + // capability-filtered view of `updated_ext`. + let cmf_payload = MessagePayload { + message: Message::text(Role::User, "fetch sensitive data"), + }; + let (cmf_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + cmf_payload, + updated_ext, + None, + ) + .await; + assert!( + cmf_result.continue_processing, + "CMF dispatch should not be blocked: violation = {:?}", + cmf_result.violation, + ); + + // ----- Verifications ----- + // Plugin with cap saw the inbound token. + assert_eq!( + reader_saw_count.load(Ordering::SeqCst), + 1, + "InboundReader with read_inbound_credentials should see 1 token", + ); + // Plugin with cap also saw the resolved subject (read_subject baseline). + assert_eq!( + reader_saw_subject.lock().unwrap().as_deref(), + Some("alice@corp.com"), + ); + // Plugin without cap saw nothing — filter_extensions stripped the slot. + assert!( + !blind_saw_creds.load(Ordering::SeqCst), + "InboundBlind without credential caps must NOT see raw_credentials", + ); +} + +// PluginError import only exists to keep the dev-dep on cpex-core +// honest if a future test needs it; unused for now. +#[allow(dead_code)] +fn _force_plugin_error_link(_e: PluginError) {} diff --git a/crates/cpex-core/tests/identity_route_e2e.rs b/crates/cpex-core/tests/identity_route_e2e.rs new file mode 100644 index 00000000..05ae3b19 --- /dev/null +++ b/crates/cpex-core/tests/identity_route_e2e.rs @@ -0,0 +1,867 @@ +// Location: ./crates/cpex-core/tests/identity_route_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end tests for the route-level `identity:` block (Slice A). +// +// Verifies the hook-specific binding semantics: +// * A route's `identity:` block is the authoritative dispatch list +// for the `identity.resolve` hook on that route. +// * The route's `plugins:` block (which means "per-route overrides" +// in APL-driven routes, "per-route binding" otherwise) does NOT +// bind plugins for the `identity.resolve` hook. +// * Dispatch order matches the order steps are declared in +// `identity:`, NOT the plugins' chain-priority values. +// * Per-step config overrides flow through the existing +// `create_override_instance` pathway. +// +// Companion tests for IdentityHook *semantics* (payload threading, +// rejection, apply_to_extensions) live in `identity_e2e.rs`. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use cpex_core::config; +use cpex_core::context::PluginContext; +use cpex_core::extensions::{MetaExtension, SubjectExtension}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +// ===================================================================== +// Test plugin: a recording identity resolver +// ===================================================================== +// +// Each instance writes its own name to a shared `Vec` ledger +// when invoked. That lets tests assert (a) which plugins fired and +// (b) in what order. Also stamps `subject.id` so the post-pipeline +// payload reflects who ran last — useful for verifying that the +// chain produced the expected accumulated state. + +struct RecordingResolver { + cfg: PluginConfig, + name: String, + ledger: Arc>>, + /// Number of times this instance has been invoked. Used to verify + /// that per-step config overrides actually produce a fresh instance + /// rather than reusing the base. + invocation_count: Arc, + /// Optional sink for what `Extensions` slots the plugin saw on + /// invocation. Used by cap-gating tests. `None` when the test + /// doesn't care about visibility. + extensions_observation: Arc>>, +} + +/// What an identity resolver saw in `Extensions` during invocation — +/// drives the cap-gating tests. Only includes slots the tests check +/// (security.subject id, labels). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct IdentityExtensionsObservation { + saw_subject_id: Option, + saw_labels: Vec, +} + +#[async_trait] +impl Plugin for RecordingResolver { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for RecordingResolver { + async fn handle( + &self, + payload: &IdentityPayload, + ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + self.ledger.lock().unwrap().push(self.name.clone()); + self.invocation_count.fetch_add(1, Ordering::SeqCst); + + // Capability-gating observation. cpex-core's executor calls + // `filter_extensions(&ext, &caps)` BEFORE handing us `ext`, + // so this snapshot reflects exactly what our declared + // capabilities expose. + *self.extensions_observation.lock().unwrap() = + Some(IdentityExtensionsObservation { + saw_subject_id: ext + .security + .as_ref() + .and_then(|s| s.subject.as_ref()) + .and_then(|s| s.id.clone()), + saw_labels: ext + .security + .as_ref() + .map(|s| s.labels.iter().cloned().collect()) + .unwrap_or_default(), + }); + + let mut updated = payload.clone(); + updated.subject = Some(SubjectExtension { + id: Some(self.name.clone()), + ..Default::default() + }); + PluginResult::modify_payload(updated) + } +} + +// ===================================================================== +// Test factory — used to build plugin instances from a config block +// so route-level `config:` overrides can produce fresh instances via +// `create_override_instance`. +// ===================================================================== + +struct RecordingFactory { + ledger: Arc>>, + /// Count of *factory invocations* (i.e. instance constructions). + /// Distinct from `invocation_count` on individual plugins — + /// asserts that a config override produced a NEW instance. + factory_calls: Arc, + /// Optional shared observation sink — when set, every plugin + /// the factory builds writes its extensions-view snapshot here + /// on invocation. The test holds the same Arc and reads it + /// after dispatch. `None` means observations are off (existing + /// tests don't need them and shouldn't pay the wiring cost). + observation_sink: Option>>>, +} + +impl PluginFactory for RecordingFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + self.factory_calls.fetch_add(1, Ordering::SeqCst); + let plugin = Arc::new(RecordingResolver { + cfg: config.clone(), + name: config.name.clone(), + ledger: Arc::clone(&self.ledger), + invocation_count: Arc::new(AtomicUsize::new(0)), + extensions_observation: self + .observation_sink + .clone() + .unwrap_or_else(|| Arc::new(Mutex::new(None))), + }); + let adapter: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + Ok(PluginInstance { + plugin: plugin as Arc, + handlers: vec![(HOOK_IDENTITY_RESOLVE, adapter)], + }) + } +} + +// ===================================================================== +// Test helpers +// ===================================================================== + +/// Build the request Extensions with MetaExtension set so route +/// filtering kicks in. Without `meta`, the filter falls through to +/// chain dispatch (all entries returned) — that's the wrong code +/// path to be testing. +fn ext_for_tool(tool_name: &str) -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".to_string()), + entity_name: Some(tool_name.to_string()), + ..Default::default() + })), + ..Default::default() + } +} + +fn build_payload(token: &str) -> IdentityPayload { + IdentityPayload::new(token, TokenSource::Bearer) +} + +/// Standard set-up: PluginManager with the recording factory +/// registered, plus a shared ledger and factory-call counter the +/// test asserts on. Doesn't wire extensions observation — +/// existing tests don't need it. +fn manager_with_recording_factory() -> ( + Arc, + Arc>>, + Arc, +) { + let ledger = Arc::new(Mutex::new(Vec::new())); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "recording", + Box::new(RecordingFactory { + ledger: Arc::clone(&ledger), + factory_calls: Arc::clone(&factory_calls), + observation_sink: None, + }), + ); + (mgr, ledger, factory_calls) +} + +/// Cap-gating-flavored set-up: also returns a shared `observation_sink` +/// the test holds onto so it can inspect what extensions the plugin +/// actually saw after invocation. Every plugin the factory builds +/// writes its observation to this shared Arc (latest wins). +fn manager_with_observing_factory() -> ( + Arc, + Arc>>, + Arc>>, +) { + let ledger = Arc::new(Mutex::new(Vec::new())); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observation_sink: Arc>> = + Arc::new(Mutex::new(None)); + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory( + "recording", + Box::new(RecordingFactory { + ledger: Arc::clone(&ledger), + factory_calls: Arc::clone(&factory_calls), + observation_sink: Some(Arc::clone(&observation_sink)), + }), + ); + (mgr, ledger, observation_sink) +} + +// ===================================================================== +// Scenarios +// ===================================================================== + +/// Baseline: route's `identity:` block dispatches the listed plugins, +/// in declared order, for `identity.resolve`. The ledger should +/// reflect the YAML order verbatim — proves the per-route binding + +/// preserved order story end-to-end. +#[tokio::test] +async fn route_identity_block_dispatches_in_declared_order() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + // Three identity plugins, all registered under `identity.resolve`. + // Route declares them in REVERSE priority order to prove that + // routing follows the `identity:` declaration, not chain priority. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: jwt-a + kind: recording + hooks: [identity.resolve] + priority: 10 + - name: jwt-b + kind: recording + hooks: [identity.resolve] + priority: 20 + - name: jwt-c + kind: recording + hooks: [identity.resolve] + priority: 30 + +routes: + - tool: get_weather + identity: + - jwt-c # priority 30 — would naturally run LAST in chain order + - jwt-a # priority 10 — would naturally run FIRST + - jwt-b # priority 20 +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + + assert!( + result.continue_processing, + "pipeline should allow; violation = {:?}", + result.violation, + ); + + // Order matches the YAML's `identity:` declaration, NOT plugin priority. + let firings = ledger.lock().unwrap().clone(); + assert_eq!(firings, vec!["jwt-c", "jwt-a", "jwt-b"]); +} + +/// `identity:` is hook-specific. Plugins in the route's `plugins:` +/// block (which means "per-route overrides" in APL-driven routes +/// and "per-route binding" otherwise) must NOT fire for the +/// identity.resolve hook. This is the load-bearing test for +/// Option 1 — the design decision that `identity:` is its own +/// dispatch list, independent of `plugins:`. +#[tokio::test] +async fn route_plugins_block_does_not_bind_identity_resolve() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + // The route declares `identity:` with corp-jwt, and `plugins:` + // with rogue-jwt. rogue-jwt also registers under identity.resolve + // — but should NOT fire for the identity.resolve hook on this + // route because it's listed in `plugins:`, not `identity:`. + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + - name: rogue-jwt + kind: recording + hooks: [identity.resolve] + +routes: + - tool: get_weather + identity: + - corp-jwt + plugins: + - rogue-jwt +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + + // Only corp-jwt fired — rogue-jwt was in `plugins:`, not + // `identity:`, so it's NOT bound for this hook on this route. + assert_eq!(ledger.lock().unwrap().clone(), vec!["corp-jwt"]); +} + +/// A route with no `identity:` block produces zero identity +/// dispatches even when the entity_type / entity_name match. The +/// plugins ARE registered under identity.resolve, but no route +/// binds them, so the route-filter returns an empty entry list. +#[tokio::test] +async fn route_without_identity_block_dispatches_no_resolvers() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +routes: + - tool: get_weather + # No identity: block. + plugins: + - corp-jwt +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + + // No identity plugins fired — `identity:` was absent, so the + // route binds nothing for the identity.resolve hook even though + // corp-jwt is in `plugins:`. + assert!(ledger.lock().unwrap().is_empty()); +} + +/// A route declared for a different tool doesn't bind identity for +/// this request — proves scope/entity matching still works under +/// the new resolver path. +#[tokio::test] +async fn identity_route_filter_respects_entity_match() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +routes: + - tool: get_compensation + identity: + - corp-jwt +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + // Request for a DIFFERENT tool — corp-jwt should not fire. + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("unrelated_tool"), + None, + ) + .await; + assert!(result.continue_processing); + assert!( + ledger.lock().unwrap().is_empty(), + "identity must NOT fire for a non-matching route", + ); +} + +/// Per-step `config_override` produces a fresh plugin instance via +/// the existing `create_override_instance` pathway. The factory +/// call count goes up by one each time the route's identity step +/// is dispatched with an override — proves the wrapper around +/// `resolve_identity_plugins_for_route` correctly threads the +/// override through to `filter_entries_by_route`'s override branch. +#[tokio::test] +async fn per_step_config_override_produces_fresh_instance() { + let (mgr, _ledger, factory_calls) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + config: + audience: default-aud + +routes: + - tool: get_weather + identity: + - name: corp-jwt + config: + audience: route-specific-aud +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + // Sanity: factory was called once for the base plugin during + // load_config. Track from here. + let base_calls = factory_calls.load(Ordering::SeqCst); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + + // One additional factory call for the override instance. + assert_eq!( + factory_calls.load(Ordering::SeqCst), + base_calls + 1, + "config_override should produce a new factory call", + ); +} + +/// Slice C — end-to-end inheritance: global.identity contributes to +/// the dispatch lineup for routes that declare no identity block of +/// their own. Verifies the dispatch path picks up the global layer. +#[tokio::test] +async fn global_identity_inherited_when_route_has_no_block() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +global: + identity: + - corp-jwt + +routes: + - tool: get_weather +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + assert_eq!( + ledger.lock().unwrap().clone(), + vec!["corp-jwt"], + "global identity should fire when the route declares none", + ); +} + +/// Full stack — global + tag bundle + route — in declared order. +/// Proves the merge actually flows the layers through cpex-core's +/// dispatch in the order the resolver guarantees. +#[tokio::test] +async fn global_tag_route_identity_stack_dispatches_in_order() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + - name: workday-saml + kind: recording + hooks: [identity.resolve] + - name: agent-context + kind: recording + hooks: [identity.resolve] + +global: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml + +routes: + - tool: get_compensation + meta: + tags: [finance] + identity: + - agent-context +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_compensation"), + None, + ) + .await; + assert!(result.continue_processing); + + // Order: global → tag bundle → route. The ledger captures the + // actual dispatch order (preserves the resolver's stacking). + assert_eq!( + ledger.lock().unwrap().clone(), + vec!["corp-jwt", "workday-saml", "agent-context"], + ); +} + +/// Route opts out via `replace_inherited: true` — inherited layers +/// (global, tag bundles) are dropped. Only the route's steps run. +#[tokio::test] +async fn replace_inherited_drops_inherited_layers_end_to_end() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + - name: workday-saml + kind: recording + hooks: [identity.resolve] + - name: legacy-basic-auth + kind: recording + hooks: [identity.resolve] + +global: + identity: + - corp-jwt + policies: + finance: + identity: + - workday-saml + +routes: + - tool: legacy_endpoint + meta: + tags: [finance] + identity: + replace_inherited: true + steps: + - legacy-basic-auth +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("legacy_endpoint"), + None, + ) + .await; + assert!(result.continue_processing); + + // Only the route's step ran — global and tag-bundle layers + // were dropped because `replace_inherited: true`. + assert_eq!( + ledger.lock().unwrap().clone(), + vec!["legacy-basic-auth"], + ); +} + +/// `replace_inherited: true` + `steps: []` — the explicit +/// "anonymous route, no identity" knob. Zero plugins fire even +/// though global identity is configured. +#[tokio::test] +async fn replace_inherited_with_empty_steps_yields_anonymous_route() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +global: + identity: + - corp-jwt + +routes: + - tool: public_endpoint + identity: + replace_inherited: true + steps: [] +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("public_endpoint"), + None, + ) + .await; + assert!(result.continue_processing); + + assert!( + ledger.lock().unwrap().is_empty(), + "anonymous-route opt-out should suppress global identity", + ); +} + +/// Sanity that an empty Vec from the resolver (route has identity +/// but with `replace_inherited: true` and zero steps — the explicit +/// "opt out" knob) results in zero dispatches. +#[tokio::test] +async fn route_with_empty_identity_steps_dispatches_nothing() { + let (mgr, ledger, _) = manager_with_recording_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: corp-jwt + kind: recording + hooks: [identity.resolve] + +routes: + - tool: get_weather + identity: + replace_inherited: true + steps: [] +"#; + let parsed = config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool("get_weather"), + None, + ) + .await; + assert!(result.continue_processing); + assert!(ledger.lock().unwrap().is_empty()); +} + +// --------------------------------------------------------------------- +// Capability gating on the identity dispatch path. +// +// Identity plugins go through cpex-core's executor like every other +// hook family — meaning `filter_extensions(&ext, &caps)` runs before +// each handler invoke and narrows what the plugin sees to its +// declared capabilities. These tests pin that behavior for the +// route-level identity dispatch path (Slice A). +// +// Identity is unusual in that resolvers typically WRITE state (subject, +// chain) rather than read it — but they still need read capabilities +// for any extension-derived context they consult during resolution +// (e.g., a `read_meta`-gated resolver that branches on entity tags). +// --------------------------------------------------------------------- + +/// Build extensions seeded with subject + label so cap-gating tests +/// can verify what a resolver sees post-filter. +fn ext_for_tool_with_subject_and_label( + tool_name: &str, + subject_id: &str, + label: &str, +) -> Extensions { + use cpex_core::extensions::{SecurityExtension, SubjectExtension}; + let mut sec = SecurityExtension::default(); + sec.subject = Some(SubjectExtension { + id: Some(subject_id.to_string()), + ..Default::default() + }); + sec.add_label(label); + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".to_string()), + entity_name: Some(tool_name.to_string()), + ..Default::default() + })), + security: Some(Arc::new(sec)), + ..Default::default() + } +} + +/// Identity resolver declaring `read_subject` sees `subject.id` in +/// Extensions but NOT `security.labels` — the executor strips the +/// labels slot because the plugin doesn't hold `read_labels`. +#[tokio::test] +async fn identity_plugin_with_read_subject_sees_subject_but_not_labels() { + let (mgr, _ledger, sink) = manager_with_observing_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: scoped-jwt + kind: recording + hooks: [identity.resolve] + capabilities: [read_subject] + +routes: + - tool: get_weather + identity: + - scoped-jwt +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + // Extensions populated with BOTH subject (id=alice) AND a label + // (pii). The plugin should see subject only. + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool_with_subject_and_label("get_weather", "alice", "pii"), + None, + ) + .await; + assert!(result.continue_processing); + + let obs = sink + .lock() + .unwrap() + .clone() + .expect("plugin should have recorded its view"); + + assert_eq!( + obs.saw_subject_id.as_deref(), + Some("alice"), + "read_subject cap should expose subject.id", + ); + assert!( + obs.saw_labels.is_empty(), + "without read_labels, labels must be hidden — saw: {:?}", + obs.saw_labels, + ); +} + +/// Identity resolver with NO capabilities sees a fully-stripped +/// Extensions view. Negative case: confirms the executor's per-entry +/// filter actually hides slots when no cap is declared. +#[tokio::test] +async fn identity_plugin_without_caps_sees_stripped_extensions() { + let (mgr, _ledger, sink) = manager_with_observing_factory(); + + let yaml = r#" +plugin_settings: + routing_enabled: true +plugins: + - name: capless-jwt + kind: recording + hooks: [identity.resolve] + # capabilities: [] (omitted entirely; same effect) + +routes: + - tool: get_weather + identity: + - capless-jwt +"#; + let parsed = cpex_core::config::parse_config(yaml).expect("parse"); + mgr.load_config(parsed).expect("load"); + mgr.initialize().await.unwrap(); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + build_payload("eyJ.fake.jwt"), + ext_for_tool_with_subject_and_label("get_weather", "alice", "pii"), + None, + ) + .await; + assert!(result.continue_processing); + + let obs = sink + .lock() + .unwrap() + .clone() + .expect("plugin should have recorded its view"); + + assert!( + obs.saw_subject_id.is_none(), + "without read_subject, subject must be hidden — saw: {:?}", + obs.saw_subject_id, + ); + assert!( + obs.saw_labels.is_empty(), + "without read_labels, labels must be hidden", + ); +} diff --git a/crates/cpex-ffi/Cargo.toml b/crates/cpex-ffi/Cargo.toml index 73d55683..8adcb8ce 100644 --- a/crates/cpex-ffi/Cargo.toml +++ b/crates/cpex-ffi/Cargo.toml @@ -20,6 +20,19 @@ crate-type = ["lib", "cdylib", "staticlib"] [dependencies] cpex-core = { path = "../cpex-core" } +# APL governance layer — bundled so Go/Python hosts can enable APL +# policies, route handlers, and the standard plugin/PDP factories via +# the `cpex_apl_install` FFI entry point. Symbols survive in the +# staticlib because that entry point references each factory. +apl-cpex = { path = "../apl-cpex" } +apl-pii-scanner = { path = "../apl-pii-scanner" } +apl-audit-logger = { path = "../apl-audit-logger" } +apl-identity-jwt = { path = "../apl-identity-jwt" } +apl-delegator-oauth = { path = "../apl-delegator-oauth" } +apl-pdp-cedar-direct = { path = "../apl-pdp-cedar-direct" } +# Heavy (~200 transitive deps via the Cedarling git dep); kept out of the +# default `.a` and behind the `cedarling` feature. +apl-cedarling = { path = "../apl-cedarling", optional = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -27,5 +40,11 @@ rmp-serde = { workspace = true } serde_bytes = { workspace = true } tracing = { workspace = true } +[features] +default = [] +# Opt-in Cedarling-backed identity + PDP. Build with +# `cargo build -p cpex-ffi --features cedarling`. +cedarling = ["dep:apl-cedarling"] + [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/cpex-ffi/RELEASE.md b/crates/cpex-ffi/RELEASE.md new file mode 100644 index 00000000..0430a34b --- /dev/null +++ b/crates/cpex-ffi/RELEASE.md @@ -0,0 +1,231 @@ +# `libcpex_ffi.a` — Release Artifacts + +CPEX publishes pre-built `libcpex_ffi.a` static libraries as signed +GitHub Release artifacts. Downstream consumers (Go bindings, +language bindings, anyone embedding CPEX) link against these +without needing a Rust toolchain. + +This document covers what is published, how to consume and verify +an artifact, and the FFI ABI policy that makes the contract durable. + +> **APL bundled.** The published `.a` includes the APL (Attribute Policy +> Language) governance layer and its standard plugin/PDP factories +> (`validator/pii-scan`, `audit/logger`, `identity/jwt`, +> `delegator/oauth`, `cedar-direct`). Enable it on a manager via +> `cpex_apl_install` (Go: `PluginManager.EnableAPL()`) after +> `cpex_manager_new_default` and before `cpex_load_config`. The +> Cedarling-backed seams are **not** in the default `.a` — build with +> `cargo build -p cpex-ffi --features cedarling` to include them. + +## What is published + +Every CPEX release tagged `vMAJOR.MINOR.PATCH` (or +`vMAJOR.MINOR.PATCH-`) attaches one tarball per +supported target tuple to the GitHub Release, along with checksums +and signatures. + +### Naming and layout + +For release `vX.Y.Z` and tuple `-[-]`: + +``` +cpex-ffi-vX.Y.Z--[-].tar.gz +cpex-ffi-vX.Y.Z--[-].tar.gz.sha256 +cpex-ffi-vX.Y.Z--[-].tar.gz.sig +cpex-ffi-vX.Y.Z--[-].tar.gz.crt +``` + +Plus one aggregate integrity manifest for the whole release: + +``` +cpex-ffi-vX.Y.Z-SHA256SUMS +cpex-ffi-vX.Y.Z-SHA256SUMS.sig +cpex-ffi-vX.Y.Z-SHA256SUMS.crt +``` + +Each tarball, when extracted, contains: + +| File | Contents | +|-------------------|---------------------------------------------------| +| `libcpex_ffi.a` | Static library — the actual deliverable. | +| `VERSION` | Plain text. Keys: `version`, `git_sha`, `build_date`, `tuple`, `rust_target`. | +| `FFI_ABI` | Single integer line — FFI ABI version. See policy below. | +| `LICENSE` | Copy of CPEX's Apache-2.0 license. | + +Tarballs are flat (no leading directory). `tar xzf -C ` drops the four files directly into ``. + +### Target matrix + +| Tuple | Rust target triple | Runner | +|----------------------|---------------------------------|-----------------| +| `linux-amd64-gnu` | `x86_64-unknown-linux-gnu` | `ubuntu-latest` | +| `linux-arm64-gnu` | `aarch64-unknown-linux-gnu` | `ubuntu-22.04-arm` | +| `linux-amd64-musl` | `x86_64-unknown-linux-musl` | `ubuntu-latest` | +| `linux-arm64-musl` | `aarch64-unknown-linux-musl` | `ubuntu-22.04-arm` | +| `darwin-arm64` | `aarch64-apple-darwin` | `macos-14` | + +`darwin-amd64` and Windows targets are not built in v1. Open an +issue if you need one — adding to the matrix is mechanical. + +### Signing + +Tarballs and the aggregate `SHA256SUMS` are signed with +[cosign](https://github.com/sigstore/cosign) **keyless** via +Sigstore (Fulcio for cert issuance, Rekor for transparency). There +is no long-lived signing key — each release produces short-lived +certs bound to the GitHub Actions OIDC identity of the +`release-ffi.yaml` workflow on the canonical repo. Verification +checks both the cert subject and the OIDC issuer. + +## How to consume + +### One-shot: the helper script + +The repo ships `scripts/download-ffi-artifact.sh` — vendor it +into your build (or fetch via `raw.githubusercontent.com` pinned to +a tag) and call it before `go build` / `cargo build` / etc. + +```sh +export CPEX_FFI_VERSION=v0.9.0 +ARTIFACT_DIR=$(bash scripts/download-ffi-artifact.sh) +export CGO_LDFLAGS="-L${ARTIFACT_DIR} -lcpex_ffi" +go build ./... +``` + +What it does: + +1. Auto-detects your tuple from `uname -s` / `uname -m` (override + with `CPEX_FFI_TARGET`). +2. Downloads the tarball, `.sha256`, `.sig`, `.crt`. +3. Verifies the SHA256 — non-skippable. +4. Verifies the cosign signature against the canonical workflow + identity and OIDC issuer — skippable via + `CPEX_FFI_SKIP_COSIGN=1` only for air-gapped environments. +5. Unpacks to `${CPEX_FFI_DEST}` (default + `./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}/`). +6. Prints the absolute destination to stdout. + +Subsequent runs against the same version + dest are no-ops. + +### Manual: cosign + tar + +If you want to do it by hand: + +```sh +VER=v0.9.0 +TUPLE=linux-amd64-gnu +BASE="https://github.com/contextforge-org/cpex/releases/download/${VER}" +NAME="cpex-ffi-${VER}-${TUPLE}.tar.gz" + +curl -fsSLO "${BASE}/${NAME}" +curl -fsSLO "${BASE}/${NAME}.sha256" +curl -fsSLO "${BASE}/${NAME}.sig" +curl -fsSLO "${BASE}/${NAME}.crt" + +sha256sum -c "${NAME}.sha256" + +cosign verify-blob \ + --certificate "${NAME}.crt" \ + --signature "${NAME}.sig" \ + --certificate-identity-regexp "^https://github.com/contextforge-org/cpex/\.github/workflows/release-ffi\.yaml@refs/tags/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${NAME}" + +mkdir -p ./libcpex +tar xzf "${NAME}" -C ./libcpex +``` + +After this, `./libcpex/libcpex_ffi.a` is your link target. + +### Using with the in-tree Go binding + +`go/cpex/ffi.go` links via `#cgo LDFLAGS: -L${SRCDIR}/../../target/release -lcpex_ffi` +relative to the cpex repo layout. For downstream Go consumers that +pull `go/cpex` via `go get`, set `CGO_LDFLAGS` to point at the +unpacked artifact directory and the cgo `-L` from `LDFLAGS` will be +augmented by the env var: + +```sh +ARTIFACT_DIR=$(CPEX_FFI_VERSION=v0.9.0 bash scripts/download-ffi-artifact.sh) +CGO_LDFLAGS="-L${ARTIFACT_DIR}" go build ./... +``` + +## FFI ABI policy + +The `FFI_ABI` integer in each bundle declares the wire-level C +contract version that `libcpex_ffi.a` exposes. Language bindings +must record the ABI version they were generated against and check +it at runtime — the Go binding does this in `go/cpex/abi.go`'s +`init()` and panics on mismatch. Every other binding **must do the +same**; silent acceptance of an ABI mismatch produces undefined +behavior on every subsequent FFI call. + +### What counts as an ABI break + +A bump of `FFI_ABI_VERSION` is **required** for any of: + +- Adding, removing, or renaming an `extern "C"` function. +- Changing argument count, argument type, or return type of an + existing extern function. +- Changing the layout of a struct that crosses the boundary. +- Changing the ownership or lifetime contract of a pointer + returned from / accepted by an extern function. +- Changing the semantics of a return code for a previously-success + case (e.g. a function that used to return `RC_OK` now returns a + new code on the same input). + +A bump is **not** required for: + +- Adding a new `RC_*` code at the end of the existing range. + Existing wire codes are stable; consumers should treat unknown + codes as generic failure. +- Internal Rust refactors that leave the C surface unchanged. +- Documentation / comment changes. + +### Process + +1. The Rust author bumps `FFI_ABI_VERSION` in + `crates/cpex-ffi/src/lib.rs` in the same PR as the breaking + change. +2. All in-tree language bindings (today: `go/cpex/abi.go`'s + `expectedFFIABIVersion`) are bumped to match in the same PR. +3. `CHANGELOG.md` records the bump under **Changed** with the + from→to integers and a one-line description of what moved. +4. The release tag that ships the breaking change is a new + `MINOR` (or `MAJOR`) — never a `PATCH`. + +## Versioning + +The artifact tag matches the CPEX repo tag exactly. There is no +separate "FFI version" — `vX.Y.Z` of CPEX produces `cpex-ffi-vX.Y.Z-*` +artifacts. Prereleases (`vX.Y.Z-rc1`, `vX.Y.Z-beta.1`, +`vX.Y.Z-ffi.test.1`, etc.) publish too and land as GitHub Releases +flagged "prerelease" — they don't surface as "latest". + +The FFI ABI version is independent: a release that doesn't touch +the C surface keeps the same `FFI_ABI`, even across minor / major +CPEX bumps. + +## Reproducibility caveats + +Builds use `cargo build --release --locked`, which pins the +`Cargo.lock` resolution. Beyond that, no guarantees: + +- Timestamps in the built `.a` differ between runs. +- Compiler / OS image patch versions on the runner can shift. +- macOS code-signing metadata varies per build. + +Consumers care about `FFI_ABI` (contract stability) and SHA + cosign +(integrity + authenticity), not bit-identical reproducibility. +Adding `cargo-zigbuild` or a sysroot-pinning toolchain to harden +reproducibility is a v2 ask. + +## When something is wrong + +| Symptom | Likely cause / fix | +|----------------------------------|----------------------------------------------------------------------------| +| `cosign verify-blob` fails | Wrong `--certificate-identity-regexp` (must point at the canonical repo's `release-ffi.yaml`), or the artifact came from a fork rather than the canonical workflow. | +| sha256 mismatch | The download was corrupted or the upstream release was rewritten. Open an issue. | +| Go `init` panics with ABI mismatch | The linked `.a` and the Go binding were generated against different ABI versions. Pin both to the same CPEX tag. | +| Unsupported tuple | Your platform isn't in the matrix. Either add it (PR welcome) or build the `.a` locally from source. | +| `tar` complains about absolute paths | Bundles are flat (no leading dir). Extract with `tar xzf -C `, not into the current dir. | diff --git a/crates/cpex-ffi/src/apl.rs b/crates/cpex-ffi/src/apl.rs new file mode 100644 index 00000000..d9300469 --- /dev/null +++ b/crates/cpex-ffi/src/apl.rs @@ -0,0 +1,101 @@ +// Location: ./crates/cpex-ffi/src/apl.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// APL (Attribute Policy Language) FFI wiring. +// +// `cpex_apl_install` registers the bundled APL plugin factories and +// installs the APL config visitor on a manager so that a subsequent +// `cpex_load_config` walks `apl:` blocks and installs per-route handlers. +// +// Registration is explicit (no inventory/ctor magic): each factory is +// referenced here so its object code survives in `libcpex_ffi.a`. Adding +// a new bundled factory means adding a `register_factory` call below. +// +// Ordering: call AFTER `cpex_manager_new_default` and BEFORE +// `cpex_load_config`. The config visitor must be registered before the +// config is loaded, and the one-shot `cpex_manager_new(yaml)` path loads +// during construction — so APL is only supported via the default-manager +// flow: +// +// cpex_manager_new_default +// → cpex_apl_install +// → cpex_load_config +// → cpex_initialize + +use std::os::raw::c_int; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::Arc; + +use crate::{CpexManagerInner, RC_INVALID_HANDLE, RC_OK, RC_PANIC}; + +/// Register the bundled APL plugin factories and install the APL config +/// visitor (in-process defaults: memory session store, default baseline +/// capabilities) on `mgr`. +/// +/// Bundled plugin factories (registered by `kind`): +/// - `validator/pii-scan` → apl-pii-scanner +/// - `audit/logger` → apl-audit-logger +/// - `identity/jwt` → apl-identity-jwt +/// - `delegator/oauth` → apl-delegator-oauth +/// +/// Bundled PDP factory (consulted for `global.apl.pdp[]` entries): +/// - `cedar-direct` → apl-pdp-cedar-direct +/// +/// With the `cedarling` cargo feature, the Cedarling-backed identity and +/// PDP seams are additionally wired. +/// +/// Returns `RC_OK` on success, `RC_INVALID_HANDLE` if `mgr` is null, or +/// `RC_PANIC` if registration panicked (caught at the FFI boundary). +/// +/// # Safety +/// `mgr` must be a valid handle returned by `cpex_manager_new_default` +/// (or `cpex_manager_new`) and not yet shut down. +#[no_mangle] +pub unsafe extern "C" fn cpex_apl_install(mgr: *const CpexManagerInner) -> c_int { + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let result = catch_unwind(AssertUnwindSafe(|| { + // Plugin factories — registered by `kind` string. Must happen + // before load_config so the manager can instantiate plugins whose + // YAML `kind:` matches. + inner.manager.register_factory( + apl_pii_scanner::KIND, + Box::new(apl_pii_scanner::PiiScannerFactory), + ); + inner.manager.register_factory( + apl_audit_logger::KIND, + Box::new(apl_audit_logger::AuditLoggerFactory), + ); + inner.manager.register_factory( + apl_identity_jwt::KIND, + Box::new(apl_identity_jwt::JwtIdentityFactory), + ); + inner.manager.register_factory( + apl_delegator_oauth::KIND, + Box::new(apl_delegator_oauth::OAuthDelegatorFactory), + ); + + // APL config visitor + PDP factories. `pdp_factories` are consulted + // for `global.apl.pdp[]` entries; cedar-direct is the bundled + // default. The visitor keeps a Weak (see + // CpexManagerInner) that upgrades during load_config_yaml. + let mut opts = apl_cpex::AplOptions::in_process(); + opts.pdp_factories = + vec![Arc::new(apl_pdp_cedar_direct::CedarDirectPdpFactory::new())]; + + apl_cpex::register_apl(&inner.manager, opts); + })); + + match result { + Ok(()) => RC_OK, + Err(_panic) => { + tracing::error!("cpex_apl_install: panic caught at FFI boundary"); + RC_PANIC + } + } +} diff --git a/crates/cpex-ffi/src/lib.rs b/crates/cpex-ffi/src/lib.rs index 760f62d9..15208453 100644 --- a/crates/cpex-ffi/src/lib.rs +++ b/crates/cpex-ffi/src/lib.rs @@ -1,7 +1,7 @@ // Location: ./crates/cpex-ffi/src/lib.rs // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor +// Authors: Teryl Taylor, Fred Araujo // // CPEX FFI — C API for embedding the CPEX runtime. // @@ -15,15 +15,19 @@ use std::os::raw::{c_char, c_int}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use cpex_core::context::PluginContextTable; use cpex_core::executor::BackgroundTasks; use cpex_core::extensions::Extensions; use cpex_core::hooks::payload::PluginPayload; +use cpex_core::identity::IdentityPayload; use cpex_core::manager::PluginManager; +// APL governance wiring — the `cpex_apl_install` extern "C" entry point. +mod apl; + // --------------------------------------------------------------------------- // FFI Result Codes // --------------------------------------------------------------------------- @@ -59,6 +63,50 @@ pub const RC_TIMEOUT: c_int = -6; /// Plugin panicked; caught by `catch_unwind` at the FFI boundary. pub const RC_PANIC: c_int = -7; +// --------------------------------------------------------------------------- +// FFI ABI Version +// --------------------------------------------------------------------------- +// +// The FFI ABI version is an integer that identifies the C-surface +// contract this crate exposes. Bump it on any breaking change to the +// C surface: +// +// - added / removed / renamed extern "C" function +// - argument count, argument type, or return type change on an +// existing function +// - layout change of a struct that crosses the boundary +// - semantic change to an existing function (e.g. new RC_* value +// returned for a previously-success case, change in pointer +// ownership) +// +// Adding a new RC_* code at the end of the existing range is *not* a +// breaking change (the wire codes are stable; consumers handle unknown +// codes as generic failure). +// +// Consumers — every language binding — MUST call `cpex_ffi_abi_version` +// at init and compare against the version their binding was generated +// for. Mismatch is a hard error: the C surface they generated against +// is not the one they're linked against. Document the binding's +// expected ABI version in its source. +// +// Bumps are recorded in CHANGELOG.md under "Changed" with the from→to +// integers and a one-line description of what moved. + +/// FFI ABI version. Bump on breaking C-surface changes; see module +/// docs above for what counts as breaking. +pub const FFI_ABI_VERSION: u32 = 2; + +/// Returns the FFI ABI version this `libcpex_ffi` was built with. +/// Language bindings call this at `init` and panic on mismatch +/// against the version they were generated for. +/// +/// Pure const access — no allocation, no runtime, no panics. Safe to +/// call from anywhere including signal handlers. +#[no_mangle] +pub extern "C" fn cpex_ffi_abi_version() -> u32 { + FFI_ABI_VERSION +} + /// Outer wall-clock timeout for any FFI-driven async call. Per-plugin /// `tokio::time::timeout` only catches cooperative-async timeouts; this /// catches CPU-bound or thread-blocking plugins that never yield. Set @@ -274,6 +322,14 @@ where /// Payload type IDs — must match Go constants. pub const PAYLOAD_GENERIC: u8 = 0; pub const PAYLOAD_CMF_MESSAGE: u8 = 1; +/// `IdentityPayload` — the input/output state of the `identity.resolve` +/// hook. Lets non-Rust hosts (the Go bindings) drive identity resolution +/// over the FFI: send the request headers in, read the resolved +/// `subject` / `client` / `raw_credentials` back out. Without this an +/// FFI host can't run `identity.resolve` (its payload is neither a +/// generic value nor a CMF message), so per-route APL gates that read +/// `subject.*` never see a principal. +pub const PAYLOAD_IDENTITY: u8 = 2; /// Deserialize a MessagePack payload based on its type ID. /// Array-indexed — O(1) lookup, zero allocation. @@ -289,6 +345,11 @@ fn deserialize_payload(payload_type: u8, bytes: &[u8]) -> Result { + let idp: IdentityPayload = rmp_serde::from_slice(bytes) + .map_err(|e| format!("identity payload deserialize failed: {}", e))?; + Ok(Box::new(idp)) + } _ => Err(format!("unknown payload type: {}", payload_type)), } } @@ -320,6 +381,13 @@ fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), Strin .map(|b| (PAYLOAD_GENERIC, b)) .map_err(|e| format!("generic payload serialize failed: {e}")); } + // Try IdentityPayload — carries the resolved subject/client/raw + // credentials back to an FFI host after `identity.resolve`. + if let Some(idp) = payload.as_any().downcast_ref::() { + return rmp_serde::to_vec_named(idp) + .map(|b| (PAYLOAD_IDENTITY, b)) + .map_err(|e| format!("identity payload serialize failed: {e}")); + } Err("unknown payload type, cannot serialize across FFI".to_string()) } @@ -332,7 +400,11 @@ fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec), Strin /// All managers share the process-singleton runtime returned by /// `shared_runtime()` — see the `SHARED_RUNTIME` doc-comment for why. pub struct CpexManagerInner { - pub manager: PluginManager, + /// Held as `Arc` so the APL config visitor — registered via + /// `cpex_apl_install` — can keep a `Weak` that upgrades + /// during `load_config_yaml`. See `apl::cpex_apl_install` and + /// `apl_cpex::register_apl`. + pub manager: Arc, } /// Opaque handle to a ContextTable (Rust-owned, not serialized). @@ -431,9 +503,12 @@ pub unsafe extern "C" fn cpex_manager_new( // silently no-op. let _ = shared_runtime(); - let manager = PluginManager::default(); + let manager = Arc::new(PluginManager::default()); - // Load config — factories must be registered separately via cpex_register_factory + // Load config — factories must be registered separately via cpex_register_factory. + // Note: this one-shot path uses `load_config` (no visitor walk), so APL is + // NOT wired here. APL requires the cpex_manager_new_default → + // cpex_apl_install → cpex_load_config flow. if let Err(e) = manager.load_config(cpex_config) { tracing::error!("cpex_manager_new: load_config failed: {}", e); return ptr::null_mut(); @@ -448,7 +523,7 @@ pub unsafe extern "C" fn cpex_manager_new( #[no_mangle] pub extern "C" fn cpex_manager_new_default() -> *mut CpexManagerInner { let _ = shared_runtime(); - let manager = PluginManager::default(); + let manager = Arc::new(PluginManager::default()); Box::into_raw(Box::new(CpexManagerInner { manager })) } @@ -479,17 +554,22 @@ pub unsafe extern "C" fn cpex_load_config( None => return RC_INVALID_INPUT, }; - let cpex_config = match cpex_core::config::parse_config(yaml) { - Ok(c) => c, - Err(e) => { - tracing::error!("cpex_load_config: config parse failed: {}", e); - return RC_PARSE_ERROR; - } - }; + // Validate first (duplicate plugin names, route shape) — preserves the + // RC_PARSE_ERROR contract. We discard the parsed value and hand the raw + // YAML to `load_config_yaml`, which re-parses into both a typed + // CpexConfig and a raw serde_yaml::Value so registered config visitors + // (e.g. the APL visitor installed by cpex_apl_install) can walk the + // `apl:` blocks and install per-route handlers. Plain `load_config` + // does NOT run that visitor walk. + if let Err(e) = cpex_core::config::parse_config(yaml) { + tracing::error!("cpex_load_config: config parse failed: {}", e); + return RC_PARSE_ERROR; + } - // load_config is sync (no .await), but we still wrap in catch_unwind - // so a panic in serde / config validation doesn't unwind across FFI. - let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config(cpex_config))); + // load_config_yaml is sync (no .await), but we still wrap in catch_unwind + // so a panic in serde / config validation / a visitor doesn't unwind + // across FFI. + let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config_yaml(yaml))); match load_result { Ok(Ok(())) => RC_OK, Ok(Err(e)) => { @@ -647,7 +727,29 @@ pub unsafe extern "C" fn cpex_plugin_names( /// Returns MessagePack-encoded PipelineResult + opaque handles for /// context table and background tasks. /// -/// Returns 0 on success, -1 on failure. +/// # Ownership contract +/// +/// **The caller's input `context_table` is unconditionally consumed +/// by this function** — even on error paths (RC_INVALID_HANDLE, +/// RC_INVALID_INPUT, RC_PARSE_ERROR, RC_TIMEOUT, RC_PANIC, etc.). +/// The Box is freed inside `cpex_invoke`; the caller's pointer is +/// dead once this function returns. This mirrors the pattern used +/// by `cpex_wait_background` and lets the Go binding nil its handle +/// unconditionally after the call without leaking the underlying Box. +/// +/// On `RC_OK`, a **fresh** `CpexContextTableInner` Box is allocated +/// and its raw pointer is written to `*context_table_out`. On any +/// non-OK return, `*context_table_out` is left as a null pointer +/// (initialized at function entry). The other out parameters +/// (`result_msgpack_out`, `result_len_out`, `bg_handle_out`) follow +/// the same discipline: null/zero on error, populated on success. +/// +/// Pre-P0-1 the function consumed the input only after validation +/// passed but before `run_safely`. On `RC_TIMEOUT` / `RC_PANIC` the +/// input had been consumed but `*context_table_out` was never written, +/// so the Go wrapper kept its stale handle and a subsequent +/// `ContextTable.Close()` ran `cpex_release_context_table` on +/// already-freed memory. /// /// # Safety /// All pointer parameters must be valid or NULL where documented. @@ -672,7 +774,43 @@ pub unsafe extern "C" fn cpex_invoke( context_table_out: *mut *mut CpexContextTableInner, bg_handle_out: *mut *mut CpexBackgroundTasksInner, ) -> c_int { - // Validate manager handle + // Initialize all out params to safe defaults. Any early return + // from here on leaves a consistent state for the caller: every + // out pointer is null/zero, so a downstream attempt to dereference + // produces a clean null-deref crash rather than reading uninit + // stack memory. The success path overwrites these at the end. + *result_msgpack_out = std::ptr::null_mut(); + *result_len_out = 0; + *context_table_out = std::ptr::null_mut(); + *bg_handle_out = std::ptr::null_mut(); + + // Take ownership of the input context_table *immediately*, before + // any validation that could return an error code. From this point + // on, the caller's `context_table` pointer is dead — equivalent + // to free'd memory from the caller's perspective. This mirrors + // how `cpex_wait_background` handles `bg_handle`: ownership + // transfers on entry, the caller nils its reference, and Rust + // is responsible for the Box's lifetime from then on. Pre-fix, + // consumption happened mid-function after some validations, which + // meant validation errors left the input alive (one ownership + // model) and post-validation errors left it consumed without + // writing `*context_table_out` (a *different* ownership model). + // Two contracts in one function is exactly what produced the + // P0-1 UAF. + let input_ctx_table: Option = if context_table.is_null() { + None + } else { + // Box::from_raw consumes the allocation; it'll drop at the + // end of this scope if not moved into Some(...). When moved + // into Some(...), the table value lives until invoke_by_name + // either uses it or it's dropped on a Future-cancellation + // path (RC_TIMEOUT). Either way the Box is gone. + let ct = Box::from_raw(context_table); + Some(ct.table) + }; + + // Validate manager handle. `input_ctx_table` already owns the + // input data — if we return here, it drops cleanly. let inner = match mgr.as_ref() { Some(m) => m, None => return RC_INVALID_HANDLE, @@ -715,22 +853,17 @@ pub unsafe extern "C" fn cpex_invoke( Extensions::default() }; - // Get or create context table - let ctx_table: Option = if context_table.is_null() { - None - } else { - let ct = Box::from_raw(context_table); - Some(ct.table) - }; - // Invoke the hook with wall-clock timeout + panic catch. let (mut result, bg) = match run_safely( inner .manager - .invoke_by_name(name, payload, extensions, ctx_table), + .invoke_by_name(name, payload, extensions, input_ctx_table), "cpex_invoke", ) { SafeRun::Ok(r) => r, + // *context_table_out is already null (set at function entry); + // the input table has been consumed by invoke_by_name's call + // frame and dropped. Caller's handle is dead, no replacement. other => return other.rc(), // RC_TIMEOUT or RC_PANIC; already logged }; @@ -801,6 +934,257 @@ pub unsafe extern "C" fn cpex_invoke( RC_OK } +/// Fused `identity.resolve` + hook invoke. +/// +/// Runs `identity.resolve` and the named hook in ONE FFI call so the +/// resolved `Extensions` — including `raw_credentials`, whose inbound +/// tokens are `#[serde(skip)]` + `Zeroizing` and therefore cannot survive +/// an FFI round-trip — flow from identity into the hook entirely in Rust +/// memory. This is the FFI analogue of an in-process host calling +/// `IdentityPayload::apply_to_extensions(...)` between hooks; it lets +/// out-of-process hosts (Go / Python / WASM) drive `delegate()` flows +/// that need the inbound bearer token, which a two-call +/// resolve-then-invoke sequence loses on the way back out. +/// +/// `identity_msgpack` is a `PAYLOAD_IDENTITY`-shaped `IdentityPayload` +/// carrying request headers. When `identity_len <= 0` or no +/// `identity.resolve` hook is registered, this degrades to a plain hook +/// invoke against the supplied extensions. If `identity.resolve` denies +/// (e.g. bad token), that denial is returned and the hook does not run. +/// +/// # Safety / ownership +/// Identical contract to [`cpex_invoke`]: the input `context_table` is +/// consumed unconditionally (used for the hook invoke; the internal +/// identity invoke gets a fresh one); out params are null/zero on error +/// and populated on `RC_OK`. +#[no_mangle] +pub unsafe extern "C" fn cpex_invoke_resolved( + mgr: *const CpexManagerInner, + identity_msgpack: *const u8, + identity_len: c_int, + hook_name: *const c_char, + hook_len: c_int, + payload_type: u8, + payload_msgpack: *const u8, + payload_len: c_int, + extensions_msgpack: *const u8, + extensions_len: c_int, + context_table: *mut CpexContextTableInner, // NULL for first call + result_msgpack_out: *mut *mut u8, + result_len_out: *mut c_int, + context_table_out: *mut *mut CpexContextTableInner, + bg_handle_out: *mut *mut CpexBackgroundTasksInner, +) -> c_int { + *result_msgpack_out = std::ptr::null_mut(); + *result_len_out = 0; + *context_table_out = std::ptr::null_mut(); + *bg_handle_out = std::ptr::null_mut(); + + // Consume the input context table up front (used for the hook invoke), + // matching cpex_invoke's unconditional-consume ownership contract. + let input_ctx_table: Option = if context_table.is_null() { + None + } else { + Some(Box::from_raw(context_table).table) + }; + + let inner = match mgr.as_ref() { + Some(m) => m, + None => return RC_INVALID_HANDLE, + }; + + let name = match c_str_to_slice(hook_name, hook_len) { + Some(s) => s, + None => return RC_INVALID_INPUT, + }; + + let payload_bytes = match c_bytes_to_slice(payload_msgpack, payload_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + let payload: Box = match deserialize_payload(payload_type, payload_bytes) { + Ok(p) => p, + Err(e) => { + tracing::error!("cpex_invoke_resolved: {}", e); + return RC_PARSE_ERROR; + } + }; + + let base_extensions: Extensions = if extensions_len > 0 { + let ext_bytes = match c_bytes_to_slice(extensions_msgpack, extensions_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + match rmp_serde::from_slice(ext_bytes) { + Ok(e) => e, + Err(e) => { + tracing::error!("cpex_invoke_resolved: extensions deserialize failed: {}", e); + return RC_PARSE_ERROR; + } + } + } else { + Extensions::default() + }; + + // ---- Identity resolution (in-process) ---- + // Resolve identity and merge the result into the extensions BEFORE the + // hook runs, so raw_credentials (skip-serialized across FFI) reach the + // hook in Rust memory. Skipped when no identity payload was supplied or + // no identity.resolve hook is registered. + let merged_extensions: Extensions = if identity_len > 0 + && inner + .manager + .has_hooks_for(cpex_core::identity::HOOK_IDENTITY_RESOLVE) + { + let id_bytes = match c_bytes_to_slice(identity_msgpack, identity_len) { + Some(b) => b, + None => return RC_INVALID_INPUT, + }; + let id_payload: IdentityPayload = match rmp_serde::from_slice(id_bytes) { + Ok(p) => p, + Err(e) => { + tracing::error!( + "cpex_invoke_resolved: identity payload deserialize failed: {}", + e + ); + return RC_PARSE_ERROR; + } + }; + + let (id_result, _id_bg) = match run_safely( + inner.manager.invoke_by_name( + cpex_core::identity::HOOK_IDENTITY_RESOLVE, + Box::new(id_payload), + Extensions::default(), + None, + ), + "cpex_invoke_resolved/identity", + ) { + SafeRun::Ok(r) => r, + other => return other.rc(), + }; + + // Identity denied (bad / missing credential): surface that denial + // as the result; the hook never runs. Identity resolvers (jwt, + // mtls) are synchronous and don't spawn background tasks, so the + // identity bg is dropped. + if !id_result.continue_processing { + return finish_pipeline_result( + id_result, + None, + payload_type, + result_msgpack_out, + result_len_out, + context_table_out, + bg_handle_out, + ); + } + + match IdentityPayload::from_pipeline_result(&id_result) { + Some(resolved) => resolved.apply_to_extensions(base_extensions), + None => base_extensions, + } + } else { + base_extensions + }; + + // ---- Hook invoke with the identity-enriched extensions ---- + let (result, bg) = match run_safely( + inner + .manager + .invoke_by_name(name, payload, merged_extensions, input_ctx_table), + "cpex_invoke_resolved", + ) { + SafeRun::Ok(r) => r, + other => return other.rc(), + }; + + finish_pipeline_result( + result, + Some(bg), + payload_type, + result_msgpack_out, + result_len_out, + context_table_out, + bg_handle_out, + ) +} + +/// Serialize a `(PipelineResult, Option)` into the FFI +/// out-params. Shared tail for [`cpex_invoke_resolved`]'s hook-result and +/// identity-denial paths; mirrors [`cpex_invoke`]'s inline serialization. +/// `bg` is `None` on the identity-denial path (no hook background tasks to +/// hand back) — `*bg_handle_out` is then left null. +/// +/// # Safety +/// Out pointers must be writable. Returns `RC_OK` on success or +/// `RC_SERIALIZE_ERROR` if the result can't be MessagePack-encoded. +unsafe fn finish_pipeline_result( + mut result: cpex_core::executor::PipelineResult, + bg: Option, + payload_type: u8, + result_msgpack_out: *mut *mut u8, + result_len_out: *mut c_int, + context_table_out: *mut *mut CpexContextTableInner, + bg_handle_out: *mut *mut CpexBackgroundTasksInner, +) -> c_int { + let (result_payload_type, modified_payload_bytes) = match result.modified_payload.as_ref() { + None => (payload_type, None), + Some(p) => match serialize_payload(p.as_ref()) { + Ok((t, b)) => (t, Some(b)), + Err(e) => { + tracing::warn!("cpex_invoke_resolved: dropped modified payload — {}", e); + result.errors.push(cpex_core::error::PluginErrorRecord { + plugin_name: "".to_string(), + message: format!("modified payload could not be serialized across FFI: {e}"), + code: Some("ffi_serialize_error".to_string()), + details: std::collections::HashMap::new(), + proto_error_code: None, + }); + (payload_type, None) + } + }, + }; + + let modified_extensions_bytes: Option> = result + .modified_extensions + .as_ref() + .and_then(|ext| rmp_serde::to_vec_named(ext).ok()); + + let ffi_result = FfiPipelineResult { + continue_processing: result.continue_processing, + violation: result.violation, + errors: result.errors, + metadata: result.metadata, + payload_type: result_payload_type, + modified_payload: modified_payload_bytes, + modified_extensions: modified_extensions_bytes, + }; + + let result_bytes = match rmp_serde::to_vec_named(&ffi_result) { + Ok(b) => b, + Err(e) => { + tracing::error!("cpex_invoke_resolved: result serialize failed: {}", e); + return RC_SERIALIZE_ERROR; + } + }; + + let (ptr, len) = alloc_bytes(&result_bytes); + *result_msgpack_out = ptr; + *result_len_out = len; + + *context_table_out = Box::into_raw(Box::new(CpexContextTableInner { + table: result.context_table, + })); + + *bg_handle_out = match bg { + Some(b) => Box::into_raw(Box::new(CpexBackgroundTasksInner { tasks: b })), + None => std::ptr::null_mut(), + }; + + RC_OK +} + // --------------------------------------------------------------------------- // Background Tasks // --------------------------------------------------------------------------- @@ -1049,7 +1433,7 @@ mod tests { // Touch the shared runtime so it's initialized; tests use it // rather than a per-manager runtime. let _ = shared_runtime(); - let manager = cpex_core::manager::PluginManager::default(); + let manager = Arc::new(cpex_core::manager::PluginManager::default()); Box::into_raw(Box::new(CpexManagerInner { manager })) } @@ -1310,4 +1694,53 @@ mod tests { assert_eq!(cpex_is_initialized(ptr::null()), 0); } } + + #[test] + fn cpex_apl_install_rejects_null_handle() { + unsafe { + assert_eq!(crate::apl::cpex_apl_install(ptr::null()), RC_INVALID_HANDLE); + } + } + + /// Full APL flow through the FFI surface: default manager → + /// cpex_apl_install (registers bundled factories + APL visitor) → + /// cpex_load_config over an `apl:`-annotated YAML using a bundled + /// plugin kind (`audit/logger`) → cpex_initialize. Proves the visitor + /// walk runs (load uses load_config_yaml) and the bundled factory is + /// reachable, so the plugin actually instantiates. + #[test] + fn cpex_apl_install_then_load_apl_config_initializes() { + const YAML: &str = r#" +plugins: + - name: auditor + kind: audit/logger + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(auditor)" +"#; + unsafe { + let mgr = build_test_manager(); + + assert_eq!(crate::apl::cpex_apl_install(mgr), RC_OK); + + let rc = cpex_load_config(mgr, YAML.as_ptr() as *const c_char, YAML.len() as c_int); + assert_eq!(rc, RC_OK, "load of APL config should succeed"); + + assert_eq!(cpex_initialize(mgr), RC_OK); + + // The bundled `audit/logger` factory instantiated a plugin on + // cmf.tool_pre_invoke — proves cpex_apl_install wired the kind. + assert!(cpex_plugin_count(mgr) >= 1); + let hook = "cmf.tool_pre_invoke"; + assert_eq!( + cpex_has_hooks_for(mgr, hook.as_ptr() as *const c_char, hook.len() as c_int), + 1, + ); + + cpex_shutdown(mgr); + } + } } diff --git a/crates/cpex-orchestration/Cargo.toml b/crates/cpex-orchestration/Cargo.toml new file mode 100644 index 00000000..1f221e2d --- /dev/null +++ b/crates/cpex-orchestration/Cargo.toml @@ -0,0 +1,34 @@ +# Location: ./crates/cpex-orchestration/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Shared async orchestration primitives. +# +# This crate is a leaf utility — no internal workspace dependencies. +# Provides the JoinSet-based concurrent runner that both: +# * `cpex-core::executor::run_concurrent_phase` — fans out concurrent +# plugins for one hook +# * `apl-core::evaluator` — fans out the effects in an APL +# `parallel:` block +# share. +# +# Speaks generic `Future` + an `is_deny` predicate, not any +# domain types. Each caller adapts its concepts to this surface. + +[package] +name = "cpex-orchestration" +description = "Async concurrency primitives shared by the CPEX runtime" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] + +[dependencies] +tokio = { workspace = true, features = ["rt", "time", "macros"] } +futures = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } diff --git a/crates/cpex-orchestration/src/lib.rs b/crates/cpex-orchestration/src/lib.rs new file mode 100644 index 00000000..ff884fb4 --- /dev/null +++ b/crates/cpex-orchestration/src/lib.rs @@ -0,0 +1,449 @@ +// Location: ./crates/cpex-orchestration/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Async concurrency primitives shared by the CPEX runtime. +// +// Two callers today, both running "N async branches concurrently with +// optional short-circuit on first deny": +// +// * `cpex-core::executor::run_concurrent_phase` — fans out concurrent +// plugins for one hook event +// * `apl-core::evaluator::dispatch_parallel` — fans out the effects +// inside an APL `parallel:` block +// +// Both want the same mechanics — `tokio::task::JoinSet` keyed by task +// id, react-to-results-as-they-arrive, optional `abort_all` on first +// deny, per-branch timeout. Without a shared primitive, both would +// reinvent the pattern (slightly differently) and drift. +// +// This crate exposes a single generic function `run_branches`. It +// speaks `Future` + an `is_deny` predicate — no domain +// concepts. Each caller adapts its types (HookEntry, EffectOutcome, +// Decision, …) at the boundary. + +#![deny(rust_2018_idioms)] + +use std::collections::HashMap; +use std::time::Duration; + +use futures::future::BoxFuture; +use tokio::task::{Id, JoinSet}; +use tokio::time::timeout; + +// ===================================================================== +// Public API +// ===================================================================== + +/// Configuration knobs for [`run_branches`]. +#[derive(Debug, Clone, Copy)] +pub struct BranchConfig { + /// Maximum time each individual branch is allowed to run before + /// being recorded as `BranchOutcome::TimedOut`. `None` disables + /// the per-branch timeout (relies on cancellation from + /// `short_circuit_on_deny` and the outer caller). + pub timeout_per_branch: Option, + + /// When `true`, abort the remaining branches as soon as the first + /// branch returns a result satisfying the `is_deny` predicate. + /// Aborted branches are returned as `BranchOutcome::Aborted`. + pub short_circuit_on_deny: bool, +} + +impl Default for BranchConfig { + fn default() -> Self { + Self { + timeout_per_branch: None, + short_circuit_on_deny: true, + } + } +} + +/// What happened to one branch in [`run_branches`]. +/// +/// Branches always return results in the **input order** (index 0 +/// first, even if it physically finished last). Callers that care +/// about wall-clock completion order need to add their own +/// timestamping inside the branch future. +#[derive(Debug)] +pub enum BranchOutcome { + /// Branch ran to completion within its timeout and produced `T`. + Completed(T), + /// Branch exceeded its `timeout_per_branch`. Callers typically + /// treat this as a deny / failure depending on policy. + TimedOut, + /// Branch was cancelled before completion because an earlier + /// branch tripped `short_circuit_on_deny`. Distinguishable from + /// `TimedOut` so audit/logging can tell whether the framework + /// or the caller's own time budget killed the task. + Aborted, + /// Branch's spawned task panicked. Carries the panic payload's + /// `Display` representation for logging — the typed payload is + /// dropped (JoinError doesn't preserve it across boxing). + Panicked(String), +} + +impl BranchOutcome { + /// Get a reference to the completed value if the branch succeeded. + /// `None` for timeouts, aborts, and panics. + pub fn completed(&self) -> Option<&T> { + match self { + BranchOutcome::Completed(v) => Some(v), + _ => None, + } + } + + /// Consume the outcome, returning the completed value if any. + pub fn into_completed(self) -> Option { + match self { + BranchOutcome::Completed(v) => Some(v), + _ => None, + } + } +} + +/// Run `branches` concurrently, returning one [`BranchOutcome`] per +/// branch in **input order**. +/// +/// # Behaviour +/// +/// * Each branch is spawned onto the current tokio runtime via +/// `JoinSet::spawn`. The runtime must be `rt-multi-thread` for the +/// branches to actually run in parallel; single-threaded runtimes +/// will run them concurrently (interleaved) but on one OS thread. +/// * If `config.short_circuit_on_deny` is set, the moment any branch +/// completes with a result satisfying `is_deny`, all remaining +/// branches are aborted via `JoinSet::abort_all`. They surface as +/// `BranchOutcome::Aborted`. +/// * If `config.timeout_per_branch` is set, each branch is wrapped in +/// `tokio::time::timeout`. Timeouts surface as `BranchOutcome::TimedOut`. +/// * Panics inside a branch are caught (tokio's `JoinSet` returns +/// them via `JoinError::is_panic`) and surfaced as +/// `BranchOutcome::Panicked` rather than re-panicking — the +/// intent is that one misbehaving branch shouldn't take down the +/// whole orchestrator. +/// +/// # Cost notes +/// +/// * `tokio::task::spawn` has ~1 µs overhead per spawn — fine for +/// the workload sizes this is designed for (typically 2-20 +/// branches). If you need 1000+ branches, profile first. +/// * Each branch's future is `Send + 'static` (it's spawned onto a +/// task) — captured state must satisfy those bounds. Most callers +/// handle this by cloning state per branch before constructing the +/// future. +pub async fn run_branches( + branches: Vec, + config: BranchConfig, + is_deny: P, +) -> Vec> +where + T: Send + 'static, + F: std::future::Future + Send + 'static, + P: Fn(&T) -> bool + Send + Sync, +{ + let n = branches.len(); + if n == 0 { + return Vec::new(); + } + + // Spawn each branch onto the JoinSet. The spawn handle's `Id` is + // captured into `id_to_idx` so a panicked task — which surfaces as + // a `JoinError` carrying only its `Id`, not the return value — can + // still be mapped back to its input index. + let mut set: JoinSet<(usize, BranchOutcome)> = JoinSet::new(); + let mut id_to_idx: HashMap = HashMap::with_capacity(n); + for (idx, fut) in branches.into_iter().enumerate() { + let to = config.timeout_per_branch; + let handle = set.spawn(async move { + let result = match to { + None => Ok(fut.await), + Some(d) => timeout(d, fut).await, + }; + let outcome = match result { + Ok(v) => BranchOutcome::Completed(v), + Err(_) => BranchOutcome::TimedOut, + }; + (idx, outcome) + }); + id_to_idx.insert(handle.id(), idx); + } + + // Collect outcomes into a position-indexed Vec so the return order + // matches input order regardless of physical completion order. + // `None` slots get filled as branches finish; remaining `None`s + // after all completions get replaced with `Aborted` (only + // possible when short-circuit fired). + let mut slots: Vec>> = (0..n).map(|_| None).collect(); + let mut aborted = false; + + while let Some(joined) = set.join_next_with_id().await { + match joined { + Ok((_id, (idx, outcome))) => { + let halts = matches!(&outcome, BranchOutcome::Completed(v) if is_deny(v)); + slots[idx] = Some(outcome); + if halts && config.short_circuit_on_deny && !aborted { + set.abort_all(); + aborted = true; + // Don't break — we still need to drain whatever + // tasks already completed before we asked for the + // abort, so their outcomes land in their slots + // (vs. being silently lost). The drain loop + // continues until JoinSet is empty. + } + } + Err(e) => { + // A task either panicked or was cancelled by + // `abort_all`. JoinError exposes the task `Id`, which + // we look up in `id_to_idx` to recover the original + // input index. Panicked branches land in their own + // slot; cancelled ones get left as `None` and filled + // with `Aborted` post-loop. + if e.is_panic() { + let payload = format!("{:?}", e); + if let Some(&idx) = id_to_idx.get(&e.id()) { + slots[idx] = Some(BranchOutcome::Panicked(payload)); + } + } + } + } + } + + // Anything still unset was aborted by `short_circuit_on_deny`. + slots + .into_iter() + .map(|s| s.unwrap_or(BranchOutcome::Aborted)) + .collect() +} + +// ===================================================================== +// Implementation note on the generic signature +// ===================================================================== +// +// `P` is the closure type for `is_deny`. We declare it as a generic +// type parameter rather than `impl Fn(...)` so the function works +// uniformly across async runtimes and callers that need to use +// boxed predicates (`Box`) for runtime polymorphism. +// +// The `BoxFuture` import isn't strictly needed for the public API +// but is re-exported below for callers that want to build +// homogeneous branch vectors out of differently-typed futures (the +// common case in apl-core's `Effect::Parallel` dispatch, where each +// effect's future has a unique inferred type). + +/// Convenience alias re-exported from `futures` for callers building +/// type-erased branch vectors. `apl-core`'s `Effect::Parallel` +/// dispatch uses this because the per-effect futures have different +/// inferred types and need erasure to fit in a single `Vec`. +pub type ErasedBranch = BoxFuture<'static, T>; + +// ===================================================================== +// Tests +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn no_deny(_: &T) -> bool { + false + } + + #[tokio::test(flavor = "multi_thread")] + async fn all_complete_in_input_order() { + // Branches finish in REVERSE wall-clock order — sleep more + // for earlier indices. The output Vec must still be in input + // order: branch[0] → first slot, branch[2] → last slot. + let branches: Vec<_> = (0usize..3) + .map(|idx| { + Box::pin(async move { + let delay = Duration::from_millis(30 - 10 * idx as u64); + tokio::time::sleep(delay).await; + idx + }) as BoxFuture<'static, usize> + }) + .collect(); + + let out = run_branches( + branches, + BranchConfig { timeout_per_branch: None, short_circuit_on_deny: false }, + no_deny::, + ) + .await; + + assert_eq!(out.len(), 3); + for (i, outcome) in out.into_iter().enumerate() { + match outcome { + BranchOutcome::Completed(v) => assert_eq!(v, i, "input order preserved"), + other => panic!("expected Completed({}), got {:?}", i, other), + } + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn timeout_marks_branch_as_timed_out() { + let branches: Vec<_> = vec![ + Box::pin(async { + tokio::time::sleep(Duration::from_secs(60)).await; + "should not see this" + }) as BoxFuture<'static, &str>, + Box::pin(async { "quick" }) as BoxFuture<'static, &str>, + ]; + + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: Some(Duration::from_millis(50)), + short_circuit_on_deny: false, + }, + no_deny::<&str>, + ) + .await; + + assert!(matches!(out[0], BranchOutcome::TimedOut)); + assert!(matches!(out[1], BranchOutcome::Completed("quick"))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn short_circuit_on_deny_aborts_remaining() { + // Branch 0 returns Deny quickly; branches 1 and 2 are slow. + // With short_circuit, the slow ones should be Aborted. + let counter = Arc::new(AtomicUsize::new(0)); + let c0 = counter.clone(); + let c1 = counter.clone(); + let c2 = counter.clone(); + + let branches: Vec> = vec![ + Box::pin(async move { + tokio::time::sleep(Duration::from_millis(5)).await; + c0.fetch_add(1, Ordering::SeqCst); + true // deny + }), + Box::pin(async move { + tokio::time::sleep(Duration::from_secs(60)).await; + c1.fetch_add(1, Ordering::SeqCst); + false + }), + Box::pin(async move { + tokio::time::sleep(Duration::from_secs(60)).await; + c2.fetch_add(1, Ordering::SeqCst); + false + }), + ]; + + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: true, + }, + |v: &bool| *v, + ) + .await; + + assert!(matches!(out[0], BranchOutcome::Completed(true))); + assert!(matches!(out[1], BranchOutcome::Aborted)); + assert!(matches!(out[2], BranchOutcome::Aborted)); + // Only the first branch should have incremented; the slow + // ones were aborted before they got past their sleeps. + assert_eq!(counter.load(Ordering::SeqCst), 1); + } + + #[tokio::test(flavor = "multi_thread")] + async fn short_circuit_disabled_keeps_all_running() { + // Same shape as above but with short_circuit OFF — all three + // should run to completion despite branch 0 denying. + let branches: Vec> = vec![ + Box::pin(async { + tokio::time::sleep(Duration::from_millis(5)).await; + true + }), + Box::pin(async { + tokio::time::sleep(Duration::from_millis(20)).await; + false + }), + Box::pin(async { + tokio::time::sleep(Duration::from_millis(20)).await; + false + }), + ]; + + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: false, + }, + |v: &bool| *v, + ) + .await; + + assert!(matches!(out[0], BranchOutcome::Completed(true))); + assert!(matches!(out[1], BranchOutcome::Completed(false))); + assert!(matches!(out[2], BranchOutcome::Completed(false))); + } + + #[tokio::test] + async fn empty_input_returns_empty_output() { + let out: Vec> = run_branches( + Vec::>::new(), + BranchConfig::default(), + no_deny::<()>, + ) + .await; + assert!(out.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn panic_inside_branch_does_not_take_down_orchestrator() { + let branches: Vec> = vec![ + Box::pin(async { panic!("boom") }), + Box::pin(async { 42 }), + ]; + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: false, + }, + no_deny::, + ) + .await; + // Branch 1 must complete despite branch 0's panic. + assert!(out.iter().any(|o| matches!(o, BranchOutcome::Completed(42)))); + assert!(out.iter().any(|o| matches!(o, BranchOutcome::Panicked(_)))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn panic_lands_in_correct_input_slot() { + // Branch 1 panics; branches 0 and 2 succeed. The panicked + // outcome must land at index 1, not "the first empty slot." + // This guards executor consumers that key per-entry + // `on_error` policy off the branch index. + let branches: Vec> = vec![ + Box::pin(async { 10 }), + Box::pin(async { panic!("middle branch boom") }), + Box::pin(async { 30 }), + ]; + let out = run_branches( + branches, + BranchConfig { + timeout_per_branch: None, + short_circuit_on_deny: false, + }, + no_deny::, + ) + .await; + assert_eq!(out.len(), 3); + assert!(matches!(out[0], BranchOutcome::Completed(10))); + assert!( + matches!(out[1], BranchOutcome::Panicked(_)), + "panic must land at index 1, got {:?}", + out[1] + ); + assert!(matches!(out[2], BranchOutcome::Completed(30))); + } +} diff --git a/examples/go-demo/ffi/src/cmf_plugins.rs b/examples/go-demo/ffi/src/cmf_plugins.rs index a033576f..dd85aa49 100644 --- a/examples/go-demo/ffi/src/cmf_plugins.rs +++ b/examples/go-demo/ffi/src/cmf_plugins.rs @@ -265,7 +265,7 @@ impl PluginFactory for HeaderInjectorFactory { } /// Register CMF demo plugin factories on a manager. -pub fn register_cmf_factories(manager: &mut cpex_core::manager::PluginManager) { +pub fn register_cmf_factories(manager: &cpex_core::manager::PluginManager) { manager.register_factory("builtin/cmf-tool-policy", Box::new(ToolPolicyFactory)); manager.register_factory( "builtin/cmf-header-injector", diff --git a/examples/go-demo/ffi/src/demo_plugins.rs b/examples/go-demo/ffi/src/demo_plugins.rs index f27125c9..4cbbf53c 100644 --- a/examples/go-demo/ffi/src/demo_plugins.rs +++ b/examples/go-demo/ffi/src/demo_plugins.rs @@ -268,7 +268,7 @@ impl PluginFactory for AuditLoggerFactory { } /// Register all demo plugin factories on a manager. -pub fn register_demo_factories(manager: &mut cpex_core::manager::PluginManager) { +pub fn register_demo_factories(manager: &cpex_core::manager::PluginManager) { manager.register_factory("builtin/identity", Box::new(IdentityCheckerFactory)); manager.register_factory("builtin/pii", Box::new(PiiGuardFactory)); manager.register_factory("builtin/audit", Box::new(AuditLoggerFactory)); diff --git a/examples/go-demo/ffi/src/lib.rs b/examples/go-demo/ffi/src/lib.rs index 8f756f3a..8d3eb59f 100644 --- a/examples/go-demo/ffi/src/lib.rs +++ b/examples/go-demo/ffi/src/lib.rs @@ -45,12 +45,14 @@ use std::os::raw::c_int; pub unsafe extern "C" fn cpex_demo_register_factories( mgr: *mut cpex_ffi::CpexManagerInner, ) -> c_int { - let inner = match mgr.as_mut() { + let inner = match mgr.as_ref() { Some(m) => m, None => return -1, }; - demo_plugins::register_demo_factories(&mut inner.manager); - cmf_plugins::register_cmf_factories(&mut inner.manager); + // `register_factory` takes `&self`; `&inner.manager` deref-coerces + // from `Arc` to `&PluginManager`. + demo_plugins::register_demo_factories(&inner.manager); + cmf_plugins::register_cmf_factories(&inner.manager); 0 } diff --git a/examples/go-demo/go.mod b/examples/go-demo/go.mod index 4e5bff08..cf2642dc 100644 --- a/examples/go-demo/go.mod +++ b/examples/go-demo/go.mod @@ -1,12 +1,12 @@ -module github.com/contextforge-org/contextforge-plugins-framework/examples/go-demo +module github.com/contextforge-org/cpex/examples/go-demo go 1.25.4 -require github.com/contextforge-org/contextforge-plugins-framework/go/cpex v0.0.0 +require github.com/contextforge-org/cpex/go/cpex v0.0.0 require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect ) -replace github.com/contextforge-org/contextforge-plugins-framework/go/cpex => ../../go/cpex +replace github.com/contextforge-org/cpex/go/cpex => ../../go/cpex diff --git a/examples/go-demo/main.go b/examples/go-demo/main.go index 33aecc16..c93df597 100644 --- a/examples/go-demo/main.go +++ b/examples/go-demo/main.go @@ -35,7 +35,7 @@ import ( "os" "unsafe" - cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" + cpex "github.com/contextforge-org/cpex/go/cpex" ) func main() { diff --git a/go/cpex/README.md b/go/cpex/README.md index 220eef68..20486240 100644 --- a/go/cpex/README.md +++ b/go/cpex/README.md @@ -27,7 +27,7 @@ go/cpex/ ## Quick Start ```go -import cpex "github.com/contextforge-org/contextforge-plugins-framework/go/cpex" +import cpex "github.com/contextforge-org/cpex/go/cpex" // 1. Create a manager mgr, err := cpex.NewPluginManagerDefault() diff --git a/go/cpex/abi.go b/go/cpex/abi.go new file mode 100644 index 00000000..0a35af01 --- /dev/null +++ b/go/cpex/abi.go @@ -0,0 +1,50 @@ +// Location: ./go/cpex/abi.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// FFI ABI version check. +// +// On package init, calls cpex_ffi_abi_version() and panics if the +// linked libcpex_ffi reports an ABI version different from what this +// Go binding was generated against. A mismatch means the C surface +// the bindings expect is not the one libcpex_ffi exposes — every +// other cgo call in this package would have undefined behavior, so +// failing loud at init is preferred over silent corruption later. +// +// Bumping expectedFFIABIVersion is required (and only required) when +// the Rust crate bumps FFI_ABI_VERSION. See crates/cpex-ffi/src/lib.rs +// "FFI ABI Version" section for what counts as a breaking change. + +package cpex + +/* +#include + +// Duplicated from ffi.go / manager.go preambles — see the note in +// manager.go about cgo not merging declarations across files. +extern uint32_t cpex_ffi_abi_version(void); +*/ +import "C" + +import "fmt" + +// expectedFFIABIVersion is the FFI_ABI_VERSION integer this binding +// was generated against. Bump in lockstep with the Rust crate's +// FFI_ABI_VERSION whenever the C surface changes in a breaking way. +const expectedFFIABIVersion uint32 = 2 + +func init() { + actual := uint32(C.cpex_ffi_abi_version()) + if actual != expectedFFIABIVersion { + panic(fmt.Sprintf( + "cpex: FFI ABI version mismatch — Go binding expects %d, "+ + "linked libcpex_ffi reports %d. Upgrade github.com/"+ + "contextforge-org/cpex/go/cpex "+ + "to a version generated against libcpex_ffi ABI %d, "+ + "or rebuild libcpex_ffi from a CPEX commit whose "+ + "FFI_ABI_VERSION is %d.", + expectedFFIABIVersion, actual, actual, expectedFFIABIVersion, + )) + } +} diff --git a/go/cpex/apl.go b/go/cpex/apl.go new file mode 100644 index 00000000..c144ee65 --- /dev/null +++ b/go/cpex/apl.go @@ -0,0 +1,61 @@ +// Location: ./go/cpex/apl.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// APL (Attribute Policy Language) wiring. +// +// EnableAPL registers the bundled APL plugin/PDP factories and installs +// the APL config visitor on the manager via the cpex_apl_install FFI +// entry point. Call it after NewPluginManagerDefault and before +// LoadConfig so that LoadConfig walks the config's `apl:` blocks and +// installs per-route handlers. + +package cpex + +import ( + "fmt" +) + +/* +#include + +// Opaque handle — same typedef as manager.go / ffi.go. Duplicated here +// because cgo does NOT merge declarations across files' preambles; see +// the note in manager.go. Edit all copies together if the signature +// changes. +typedef void* CpexManager; + +extern int cpex_apl_install(CpexManager mgr); +*/ +import "C" + +// EnableAPL registers the bundled APL plugin and PDP factories and +// installs the APL config visitor on the manager (in-process defaults: +// memory session store, default baseline capabilities). +// +// Bundled plugin kinds: validator/pii-scan, audit/logger, identity/jwt, +// delegator/oauth. Bundled PDP kind: cedar-direct. +// +// Ordering: call after NewPluginManagerDefault and before LoadConfig. +// The one-shot NewPluginManager(yaml) constructor loads config during +// creation and therefore does NOT support APL — use the default-manager +// flow instead: +// +// mgr, _ := NewPluginManagerDefault() +// mgr.EnableAPL() +// mgr.LoadConfig(yaml) +// mgr.Initialize() +// +// On failure the returned error wraps a typed sentinel +// (ErrCpexInvalidHandle, ErrCpexPanic). +func (m *PluginManager) EnableAPL() error { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return fmt.Errorf("EnableAPL: %w", ErrCpexInvalidHandle) + } + + rc := C.cpex_apl_install(m.handle) + return errorFromRC(int(rc), "EnableAPL") +} diff --git a/go/cpex/apl_test.go b/go/cpex/apl_test.go new file mode 100644 index 00000000..d3510762 --- /dev/null +++ b/go/cpex/apl_test.go @@ -0,0 +1,73 @@ +// Location: ./go/cpex/apl_test.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Tests for APL wiring (EnableAPL). Run against the real Rust runtime +// via cgo; build the staticlib first: +// +// cargo build --release -p cpex-ffi +// go test -v ./... + +package cpex + +import ( + "errors" + "testing" +) + +// TestEnableAPLLoadsAplConfig drives the documented APL flow: +// NewPluginManagerDefault → EnableAPL → LoadConfig (APL-annotated) → +// Initialize. The bundled `audit/logger` factory must instantiate, so +// the cmf.tool_pre_invoke hook is registered after load. +func TestEnableAPLLoadsAplConfig(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + + if err := mgr.EnableAPL(); err != nil { + t.Fatalf("EnableAPL failed: %v", err) + } + + yaml := ` +plugins: + - name: auditor + kind: audit/logger + hooks: [cmf.tool_pre_invoke] +routes: + - tool: get_weather + apl: + policy: + - "plugin(auditor)" +` + if err := mgr.LoadConfig(yaml); err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + if mgr.PluginCount() < 1 { + t.Errorf("expected at least 1 plugin, got %d", mgr.PluginCount()) + } + if !mgr.HasHooksFor("cmf.tool_pre_invoke") { + t.Error("expected cmf.tool_pre_invoke hook registered after APL load") + } +} + +// TestEnableAPLAfterShutdown verifies the typed handle error is returned +// when EnableAPL is called on a shut-down manager. +func TestEnableAPLAfterShutdown(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + mgr.Shutdown() + + err = mgr.EnableAPL() + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle, got %v", err) + } +} diff --git a/go/cpex/constants.go b/go/cpex/constants.go index 45ce3858..3fb39e23 100644 --- a/go/cpex/constants.go +++ b/go/cpex/constants.go @@ -20,6 +20,10 @@ const ( PayloadGeneric uint8 = 0 // PayloadCMFMessage is a CMF MessagePayload. PayloadCMFMessage uint8 = 1 + // PayloadIdentity is an IdentityPayload — the input/output state of + // the identity.resolve hook. Send request headers in; read the + // resolved subject / client / raw credentials back out. + PayloadIdentity uint8 = 2 ) // ContentType values — the discriminator for ContentPart's tagged union. diff --git a/go/cpex/go.mod b/go/cpex/go.mod index d71e10b0..c3c06bc9 100644 --- a/go/cpex/go.mod +++ b/go/cpex/go.mod @@ -1,4 +1,4 @@ -module github.com/contextforge-org/contextforge-plugins-framework/go/cpex +module github.com/contextforge-org/cpex/go/cpex go 1.25.4 diff --git a/go/cpex/identity.go b/go/cpex/identity.go new file mode 100644 index 00000000..220df7cf --- /dev/null +++ b/go/cpex/identity.go @@ -0,0 +1,83 @@ +// Location: ./go/cpex/identity.go +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// +// IdentityPayload — Go view of the identity.resolve hook's input/output +// state (crates/cpex-core/src/identity/payload.rs). +// +// Hosts that don't run in-process Rust (i.e. the Go FFI bindings) drive +// identity resolution like this: +// +// idp := cpex.NewIdentityPayload(cpex.TokenSourceBearer, headers) +// res, ct, bg, err := mgr.InvokeByName( +// cpex.HookIdentityResolve, cpex.PayloadIdentity, idp, ext, nil) +// // On success the resolved IdentityPayload comes back as res.ModifiedPayload. +// resolved, _ := cpex.DeserializePayload[cpex.IdentityPayload](res) +// // resolved.Subject now carries roles / permissions / teams. +// +// The resolved Subject / Client / RawCredentials are then applied onto +// the Extensions passed to the downstream tool/prompt/resource hook so +// per-route APL gates (require(role.*), redact(!perm.*), Cedar) and the +// OAuth delegator can see the principal and its inbound credentials. + +package cpex + +import "github.com/vmihailenco/msgpack/v5" + +// HookIdentityResolve is the hook name the identity resolver chain is +// registered under. Matches HOOK_IDENTITY_RESOLVE in +// crates/cpex-core/src/identity/hook.rs. +const HookIdentityResolve = "identity.resolve" + +// TokenSource values — where a credential was extracted from. Wire form +// is snake_case to match the Rust TokenSource enum +// (#[serde(rename_all = "snake_case")]). +const ( + TokenSourceBearer = "bearer" + TokenSourceUserToken = "user_token" + TokenSourceMTLS = "mtls" + TokenSourceSpiffeJwtSvid = "spiffe_jwt_svid" + TokenSourceAPIKey = "api_key" +) + +// IdentityPayload mirrors the Rust IdentityPayload. Input fields +// (Source, SourceHeader, Headers, ClientHost, ClientPort) are set by the +// host before the call; output fields (Subject, Client, …) are populated +// by the resolver chain and read back from the result. +// +// raw_token is intentionally absent: it is #[serde(skip)] on the Rust +// side (zeroized, never serialized). Tokens travel in Headers — each +// jwt resolver reads its configured header (X-User-Token, Authorization) +// from there. +// +// Output slots the Go side doesn't model field-by-field are carried as +// msgpack.RawMessage so they round-trip verbatim — a host can forward +// them onto the next hook's Extensions without the bindings needing a +// typed mirror of every Rust extension. +type IdentityPayload struct { + // ----- Input ----- + Source string `msgpack:"source"` + SourceHeader string `msgpack:"source_header,omitempty"` + Headers map[string]string `msgpack:"headers,omitempty"` + ClientHost string `msgpack:"client_host,omitempty"` + ClientPort uint16 `msgpack:"client_port,omitempty"` + + // ----- Output ----- + Subject *SubjectExtension `msgpack:"subject,omitempty"` + Client msgpack.RawMessage `msgpack:"client,omitempty"` + CallerWorkload msgpack.RawMessage `msgpack:"caller_workload,omitempty"` + Delegation msgpack.RawMessage `msgpack:"delegation,omitempty"` + RawCredentials msgpack.RawMessage `msgpack:"raw_credentials,omitempty"` + ResolvedAt string `msgpack:"resolved_at,omitempty"` + RawClaims map[string]any `msgpack:"raw_claims,omitempty"` +} + +// NewIdentityPayload builds an input payload for identity.resolve. The +// header map should carry the inbound request's auth headers (lowercased +// keys — the resolvers look their configured header up case-folded). +func NewIdentityPayload(source string, headers map[string]string) IdentityPayload { + if source == "" { + source = TokenSourceBearer + } + return IdentityPayload{Source: source, Headers: headers} +} diff --git a/go/cpex/manager.go b/go/cpex/manager.go index 911bb7ec..00d7fcf3 100644 --- a/go/cpex/manager.go +++ b/go/cpex/manager.go @@ -1,7 +1,7 @@ // Location: ./go/cpex/manager.go // Copyright 2025 // SPDX-License-Identifier: Apache-2.0 -// Authors: Teryl Taylor +// Authors: Teryl Taylor, Fred Araujo // // PluginManager — Go wrapper for the CPEX plugin runtime. // @@ -75,6 +75,18 @@ extern int cpex_invoke( CpexContextTable* context_table_out, CpexBackgroundTasks* bg_handle_out ); +extern int cpex_invoke_resolved( + CpexManager mgr, + const uint8_t* identity_msgpack, int identity_len, + const char* hook_name, int hook_len, + uint8_t payload_type, + const uint8_t* payload_msgpack, int payload_len, + const uint8_t* extensions_msgpack, int extensions_len, + CpexContextTable context_table, + uint8_t** result_msgpack_out, int* result_len_out, + CpexContextTable* context_table_out, + CpexBackgroundTasks* bg_handle_out +); extern int cpex_wait_background( CpexManager mgr, CpexBackgroundTasks bg_handle, @@ -341,19 +353,18 @@ func (m *PluginManager) InvokeByName( cHookName := C.CString(hookName) defer C.free(unsafe.Pointer(cHookName)) - // Pass the context-table handle to Rust but DO NOT nil our local - // reference until we know Rust succeeded. Rust consumes the handle - // only at the moment of invoke (after all input validation), so - // pre-invoke failures (bad payload, bad extensions, etc.) leave - // the handle untouched and the caller's ContextTable remains valid. - // - // Caveat: on a post-invoke failure (rare — only result-serialization - // OOM), Rust has consumed the box but doesn't write ctOut, so the - // caller's ContextTable handle becomes dangling. The caller should - // not reuse a ContextTable after an InvokeByName error. + // Pass the context-table handle to Rust. Per the post-P0-1 FFI + // contract, `cpex_invoke` takes ownership of `ctHandle` + // UNCONDITIONALLY on entry — same pattern as `cpex_wait_background` + // with `bg_handle`. We nil our local reference immediately so the + // caller's `ContextTable` can't be accidentally reused after this + // call (its underlying Box is gone regardless of the eventual rc). + // On RC_OK a fresh handle lands in `ctOut`; on any error path + // `ctOut` stays nil and the caller's context-table chain ends here. var ctHandle C.CpexContextTable if contextTable != nil { ctHandle = contextTable.handle + contextTable.handle = nil } var resultPtr *C.uint8_t @@ -386,16 +397,13 @@ func (m *PluginManager) InvokeByName( ) if rc != 0 { + // `ctOut` is null on every non-OK return per the post-P0-1 + // contract. The caller's `contextTable.handle` is already nil + // (we cleared it above before the call), so there's no + // dangling-handle risk on error paths. return nil, nil, nil, errorFromRC(int(rc), "InvokeByName") } - // Rust succeeded — it consumed ctHandle and produced ctOut. - // NOW it's safe to nil the caller's reference (the original Box - // was consumed by Rust; its successor is in ctOut). - if contextTable != nil { - contextTable.handle = nil - } - // Deserialize result from MessagePack resultBytes := C.GoBytes(unsafe.Pointer(resultPtr), resultLen) C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(resultPtr)), resultLen) @@ -419,6 +427,116 @@ func (m *PluginManager) InvokeByName( return &result, resultCT, bg, nil } +// InvokeResolved runs identity.resolve and the named hook in a single FFI +// call. The resolved Extensions — including raw_credentials, whose inbound +// tokens are skip-serialized and so can't survive an FFI round-trip — are +// threaded from identity into the hook in Rust memory. This lets an +// out-of-process host drive delegate() flows that need the inbound bearer +// token, which a separate resolve-then-invoke pair loses. +// +// `identity` carries the request headers the resolvers read (X-User-Token, +// Authorization, …). When the manager has no identity.resolve hook +// registered, this degrades to a plain InvokeByName(hookName, ...). +// Otherwise the semantics (ContextTable threading, ownership, result +// shape) match InvokeByName. +func (m *PluginManager) InvokeResolved( + identity IdentityPayload, + hookName string, + payloadType uint8, + payload any, + extensions *Extensions, + contextTable *ContextTable, +) (*PipelineResult, *ContextTable, *BackgroundTasks, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.handle == nil { + return nil, nil, nil, fmt.Errorf("InvokeResolved: %w", ErrCpexInvalidHandle) + } + + idBytes, err := msgpack.Marshal(identity) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: identity marshal failed: %w", err) + } + payloadBytes, err := msgpack.Marshal(payload) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: payload marshal failed: %w", err) + } + var extBytes []byte + if extensions != nil { + extBytes, err = msgpack.Marshal(extensions) + if err != nil { + return nil, nil, nil, fmt.Errorf("cpex: extensions marshal failed: %w", err) + } + } + + cHookName := C.CString(hookName) + defer C.free(unsafe.Pointer(cHookName)) + + var ctHandle C.CpexContextTable + if contextTable != nil { + ctHandle = contextTable.handle + } + + var resultPtr *C.uint8_t + var resultLen C.int + var ctOut C.CpexContextTable + var bgOut C.CpexBackgroundTasks + + var idPtr *C.uint8_t + if len(idBytes) > 0 { + idPtr = (*C.uint8_t)(unsafe.Pointer(&idBytes[0])) + } + var payloadPtr *C.uint8_t + if len(payloadBytes) > 0 { + payloadPtr = (*C.uint8_t)(unsafe.Pointer(&payloadBytes[0])) + } + var extPtr *C.uint8_t + var extLen C.int + if len(extBytes) > 0 { + extPtr = (*C.uint8_t)(unsafe.Pointer(&extBytes[0])) + extLen = C.int(len(extBytes)) + } + + rc := C.cpex_invoke_resolved( + m.handle, + idPtr, C.int(len(idBytes)), + cHookName, C.int(len(hookName)), + C.uint8_t(payloadType), + payloadPtr, C.int(len(payloadBytes)), + extPtr, extLen, + ctHandle, + &resultPtr, &resultLen, + &ctOut, + &bgOut, + ) + + if rc != 0 { + return nil, nil, nil, errorFromRC(int(rc), "InvokeResolved") + } + + // Rust consumed ctHandle and produced ctOut — safe to nil our ref. + if contextTable != nil { + contextTable.handle = nil + } + + resultBytes := C.GoBytes(unsafe.Pointer(resultPtr), resultLen) + C.cpex_free_bytes((*C.uint8_t)(unsafe.Pointer(resultPtr)), resultLen) + + var result PipelineResult + if err := msgpack.Unmarshal(resultBytes, &result); err != nil { + return nil, nil, nil, fmt.Errorf("cpex: result unmarshal failed: %w", err) + } + + resultCT := &ContextTable{handle: ctOut} + runtime.SetFinalizer(resultCT, func(ct *ContextTable) { + ct.Close() + }) + + bg := &BackgroundTasks{handle: bgOut, mgr: m} + + return &result, resultCT, bg, nil +} + // Invoke is the typed invoke path. Calls InvokeByName and deserializes // the modified payload and extensions into concrete Go types. // diff --git a/go/cpex/manager_test.go b/go/cpex/manager_test.go index f5b31c5a..7dff38aa 100644 --- a/go/cpex/manager_test.go +++ b/go/cpex/manager_test.go @@ -1173,3 +1173,113 @@ func TestLoadConfigInvalidYAML(t *testing.T) { t.Error("expected error for invalid YAML") } } + +// TestInvokeByNameErrorDoesNotUAFContextTable is the regression guard +// for P0-1. Pre-fix, `cpex_invoke` consumed the input ContextTable's +// Box mid-function but didn't write *context_table_out on +// RC_TIMEOUT / RC_PANIC / RC_PARSE_ERROR — the Go wrapper kept its +// stale handle and a subsequent Close() called +// cpex_release_context_table on already-freed memory. +// +// Post-fix, the Go wrapper nils its `contextTable.handle` immediately +// after handing it to Rust (mirroring the bg_handle pattern), so even +// if Rust errors out without producing a replacement, no dangling +// handle survives. +// +// This test: +// 1. Performs a successful invoke to get a real ContextTable. +// 2. Calls InvokeByName again with that ContextTable PLUS an +// invalid payload_type that forces Rust to return RC_PARSE_ERROR +// AFTER the consumption point. +// 3. Confirms the second call errored (sanity). +// 4. Calls Close() on the original ContextTable — must NOT crash. +// Pre-fix this was a UAF (free of already-freed memory). +func TestInvokeByNameErrorDoesNotUAFContextTable(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + defer mgr.Shutdown() + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // 1. Successful first invoke — gives us a real, Rust-allocated + // ContextTable. Calling Close() on this pre-fix would have + // been the second free. + payload := map[string]any{"tool_name": "test"} + _, ctxTable, bg, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) + if err != nil { + t.Fatalf("first invoke failed: %v", err) + } + bg.Close() + if ctxTable == nil || ctxTable.handle == nil { + t.Fatal("expected a non-nil ContextTable from the first invoke") + } + + // 2. Second invoke with an UNKNOWN payload_type (99). The Rust + // side validates payload_type against its registry; an + // unknown value forces a RC_PARSE_ERROR return. Critically, + // that error path is now POST-consumption of the input + // context_table. + const unknownPayloadType uint8 = 99 + _, _, _, err = mgr.InvokeByName("hook2", unknownPayloadType, payload, &Extensions{}, ctxTable) + if err == nil { + t.Fatal("expected error from invoke with unknown payload_type") + } + + // 3. Per the P0-1 contract, ctxTable's handle was nil'd in Go + // *before* the C call returned. So whether or not Rust wrote + // *context_table_out, our local handle is nil. + if ctxTable.handle != nil { + t.Errorf("input ContextTable.handle should be nil after invoke error; got %p", ctxTable.handle) + } + + // 4. The actual UAF check: Close() must be safe. Pre-fix this + // called cpex_release_context_table on already-freed memory. + // Post-fix, Close() short-circuits on a nil handle and is a + // no-op. Either it crashes (fail) or it doesn't (pass). + ctxTable.Close() +} + +// TestInvokeByNameConsumesContextTableOnRcError pins the other half +// of the P0-1 contract — even when the manager rejects the call with +// a validation-class error (here: shutdown after first invoke), the +// caller's ContextTable handle is nil'd unconditionally. +// +// Verifies: no leak of the input Box when Rust never gets to write +// the output; Close() on the input is a safe no-op. +func TestInvokeByNameConsumesContextTableEvenOnShutdownPath(t *testing.T) { + mgr, err := NewPluginManagerDefault() + if err != nil { + t.Fatalf("NewPluginManagerDefault failed: %v", err) + } + if err := mgr.Initialize(); err != nil { + mgr.Shutdown() + t.Fatalf("Initialize failed: %v", err) + } + + payload := map[string]any{"tool_name": "test"} + _, ctxTable, bg, err := mgr.InvokeByName("hook1", PayloadGeneric, payload, &Extensions{}, nil) + if err != nil { + mgr.Shutdown() + t.Fatalf("first invoke failed: %v", err) + } + bg.Close() + + // Shut down the manager — Go-side short-circuit will return + // ErrCpexInvalidHandle WITHOUT calling cpex_invoke. The Go + // wrapper hasn't touched ctxTable yet in this case (early + // return at m.handle == nil), so ctxTable.handle remains live. + mgr.Shutdown() + + _, _, _, err = mgr.InvokeByName("hook2", PayloadGeneric, payload, &Extensions{}, ctxTable) + if !errors.Is(err, ErrCpexInvalidHandle) { + t.Errorf("expected ErrCpexInvalidHandle after shutdown, got %v", err) + } + + // Even though the Go-side short-circuit didn't transit our + // handle to Rust, Close() must still be safe — it's a legal + // thing for callers to do. + ctxTable.Close() +} diff --git a/scripts/download-ffi-artifact.sh b/scripts/download-ffi-artifact.sh new file mode 100755 index 00000000..a72eb5cc --- /dev/null +++ b/scripts/download-ffi-artifact.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Location: ./scripts/download-ffi-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Consumer-facing script. Downloads a published libcpex_ffi.a +# release tarball from the CPEX GitHub Releases, verifies its +# sha256 + cosign signature, and unpacks it into a directory ready +# for cgo to link from. +# +# Intended for use in downstream Dockerfiles and CI jobs that don't +# want a Rust toolchain. Vendor this file (or fetch it pinned by tag +# from raw.githubusercontent.com) and call it before `go build`. +# +# Inputs (env or flag-equivalent CLI args): +# CPEX_FFI_VERSION Required. Tag of the release, e.g. v0.9.0. +# CPEX_FFI_TARGET Optional. Tuple name (linux-amd64-gnu, +# linux-arm64-gnu, linux-amd64-musl, +# linux-arm64-musl, darwin-arm64). Auto-detected +# from `uname -s` / `uname -m` + libc probe if unset. +# CPEX_FFI_DEST Optional. Destination directory. Defaults to +# ./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}/. +# CPEX_FFI_REPO Optional. GitHub owner/repo override. Defaults +# to contextforge-org/cpex. +# CPEX_FFI_BASE_URL Optional. Full URL prefix override (skips the +# github.com/releases/download URL construction). +# Used for local file:// dry-runs. +# CPEX_FFI_SKIP_COSIGN +# Optional. Set to "1" to skip cosign verification. +# sha256 verification is never skipped. Only for +# air-gapped / offline environments where cosign +# cannot reach Sigstore. Document the risk. +# +# Output: +# Prints the absolute destination directory to stdout on success. +# Consumers capture it with $(bash download-ffi-artifact.sh) and +# pass to CGO_LDFLAGS as `-L${dir} -lcpex_ffi`. +# +# Idempotency: +# If ${dest}/VERSION exists and its first "version=..." line matches +# CPEX_FFI_VERSION, the script exits 0 without re-downloading. + +set -euo pipefail + +err() { echo "download-ffi-artifact: error: $*" >&2; exit 1; } +info() { echo "download-ffi-artifact: $*" >&2; } # stderr — stdout is the dest path + +: "${CPEX_FFI_VERSION:?CPEX_FFI_VERSION is required (e.g. v0.9.0)}" +CPEX_FFI_REPO="${CPEX_FFI_REPO:-contextforge-org/cpex}" + +# Detect target tuple if not provided. Inverse of the mapping in +# build-artifact.sh. +detect_tuple() { + local os arch libc="" + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Linux) + # Probe for musl vs gnu. ldd --version writes to stderr; + # musl's ldd prints "musl libc" on stderr too, gnu prints + # "GLIBC". Fallback heuristic: presence of /lib/ld-musl-*. + if (ldd --version 2>&1 || true) | grep -qi musl; then + libc="musl" + elif compgen -G "/lib/ld-musl-*" >/dev/null; then + libc="musl" + else + libc="gnu" + fi + case "$arch" in + x86_64) echo "linux-amd64-${libc}" ;; + aarch64) echo "linux-arm64-${libc}" ;; + *) err "unsupported linux arch: $arch" ;; + esac + ;; + Darwin) + case "$arch" in + arm64) echo "darwin-arm64" ;; + x86_64) echo "darwin-amd64" ;; + *) err "unsupported darwin arch: $arch" ;; + esac + ;; + *) err "unsupported OS: $os" ;; + esac +} + +CPEX_FFI_TARGET="${CPEX_FFI_TARGET:-$(detect_tuple)}" +CPEX_FFI_DEST="${CPEX_FFI_DEST:-./.cpex-ffi/${CPEX_FFI_VERSION}/${CPEX_FFI_TARGET}}" + +info "version=$CPEX_FFI_VERSION target=$CPEX_FFI_TARGET dest=$CPEX_FFI_DEST" + +# Idempotency: a successful prior run leaves a VERSION file whose +# first line is "version=". If it matches, we're done. +if [[ -f "${CPEX_FFI_DEST}/VERSION" ]]; then + existing="$(head -n1 "${CPEX_FFI_DEST}/VERSION" | sed -E 's/^version=//')" + if [[ "$existing" == "$CPEX_FFI_VERSION" ]]; then + info "already present at $CPEX_FFI_DEST (version=$existing); skipping download" + cd "$CPEX_FFI_DEST" && pwd + exit 0 + fi + info "existing VERSION ($existing) != requested ($CPEX_FFI_VERSION); re-downloading" +fi + +TARBALL_NAME="cpex-ffi-${CPEX_FFI_VERSION}-${CPEX_FFI_TARGET}.tar.gz" +BASE_URL="${CPEX_FFI_BASE_URL:-https://github.com/${CPEX_FFI_REPO}/releases/download/${CPEX_FFI_VERSION}}" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +fetch() { + local name="$1" + local url="${BASE_URL}/${name}" + info " GET $url" + if [[ "$url" == file://* ]]; then + cp "${url#file://}" "${WORK_DIR}/${name}" \ + || err "failed to copy from $url" + else + curl -fsSL --retry 3 --retry-delay 2 -o "${WORK_DIR}/${name}" "$url" \ + || err "failed to download $url" + fi +} + +info "downloading release assets" +fetch "$TARBALL_NAME" +fetch "${TARBALL_NAME}.sha256" + +# sha256 verification — non-negotiable. The .sha256 file contains +# " "; sha256sum -c reads it and checks. macOS's +# shasum -a 256 -c uses the same format. +info "verifying sha256" +if command -v sha256sum >/dev/null; then + (cd "$WORK_DIR" && sha256sum -c "${TARBALL_NAME}.sha256") +else + (cd "$WORK_DIR" && shasum -a 256 -c "${TARBALL_NAME}.sha256") +fi + +# cosign verification — opt-out only. The certificate identity is the +# workflow path; the regex permits any tag ref so re-tagged releases +# still verify. The issuer is pinned to GitHub's OIDC issuer to +# prevent Sigstore certs from other providers from passing. +if [[ "${CPEX_FFI_SKIP_COSIGN:-0}" == "1" ]]; then + info "WARN: skipping cosign verification (CPEX_FFI_SKIP_COSIGN=1)" +else + command -v cosign >/dev/null || err "cosign is required for signature verification (or set CPEX_FFI_SKIP_COSIGN=1 to bypass — not recommended)" + fetch "${TARBALL_NAME}.sig" + fetch "${TARBALL_NAME}.crt" + info "verifying cosign signature" + cosign verify-blob \ + --certificate "${WORK_DIR}/${TARBALL_NAME}.crt" \ + --signature "${WORK_DIR}/${TARBALL_NAME}.sig" \ + --certificate-identity-regexp "^https://github.com/${CPEX_FFI_REPO}/\.github/workflows/release-ffi\.yaml@refs/tags/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${WORK_DIR}/${TARBALL_NAME}" \ + >/dev/null \ + || err "cosign verification failed" +fi + +# Unpack into the destination, replacing any prior contents at that +# version (the idempotency check above already handled the +# already-present case). +info "unpacking into $CPEX_FFI_DEST" +mkdir -p "$CPEX_FFI_DEST" +# Clear stale files from a partial earlier run; safe because we only +# touch our own version-stamped dir. +find "$CPEX_FFI_DEST" -mindepth 1 -delete +tar xzf "${WORK_DIR}/${TARBALL_NAME}" -C "$CPEX_FFI_DEST" + +# Print the absolute destination so consumer scripts can capture it. +(cd "$CPEX_FFI_DEST" && pwd) +info "done" diff --git a/scripts/release/build-artifact.sh b/scripts/release/build-artifact.sh new file mode 100755 index 00000000..01b6afdc --- /dev/null +++ b/scripts/release/build-artifact.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Location: ./scripts/release/build-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Build one libcpex_ffi.a for a given Rust target triple and stage it +# into a release tarball under dist/. +# +# Invoked once per matrix tuple by .github/workflows/release-ffi.yaml. +# Safe to run locally for the host tuple to validate the bundle shape. +# +# Inputs (env): +# TARGET Required. Rust target triple, e.g. x86_64-unknown-linux-gnu. +# VERSION Required in CI. Git tag, e.g. v0.9.0. Falls back to +# `git describe --tags --dirty` for local invocations. +# DIST_DIR Optional. Output dir for tarball + .sha256. Defaults to ./dist. +# USE_CROSS Optional. If "1", build with `cross` instead of `cargo`. +# Required for cross-compiling musl/arm targets without a +# pre-installed sysroot. +# +# Outputs: +# ${DIST_DIR}/cpex-ffi-${VERSION}-${TUPLE}.tar.gz +# ${DIST_DIR}/cpex-ffi-${VERSION}-${TUPLE}.tar.gz.sha256 + +set -euo pipefail + +err() { echo "build-artifact: error: $*" >&2; exit 1; } +info() { echo "build-artifact: $*"; } + +: "${TARGET:?TARGET is required (e.g. x86_64-unknown-linux-gnu)}" +DIST_DIR="${DIST_DIR:-./dist}" +USE_CROSS="${USE_CROSS:-0}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# Version resolution. CI sets VERSION from the tag; locally fall back +# to git-describe so dev iterations get a sensible bundle name. +if [[ -z "${VERSION:-}" ]]; then + VERSION="$(git describe --tags --dirty --always 2>/dev/null || echo "v0.0.0-dev")" + info "VERSION not set; using git-describe fallback: $VERSION" +fi + +# Map Rust target triple → our tuple naming. This is the contract +# downstream consumers' download-ffi-artifact.sh inverts via uname. +case "$TARGET" in + x86_64-unknown-linux-gnu) TUPLE="linux-amd64-gnu" ;; + aarch64-unknown-linux-gnu) TUPLE="linux-arm64-gnu" ;; + x86_64-unknown-linux-musl) TUPLE="linux-amd64-musl" ;; + aarch64-unknown-linux-musl) TUPLE="linux-arm64-musl" ;; + aarch64-apple-darwin) TUPLE="darwin-arm64" ;; + x86_64-apple-darwin) TUPLE="darwin-amd64" ;; + *) err "unsupported TARGET: $TARGET (add a case in build-artifact.sh)" ;; +esac + +# Read FFI_ABI_VERSION from the crate source. Single source of truth — +# bumps in lib.rs flow into the bundle without a separate config edit. +ABI_LINE="$(grep -E '^pub const FFI_ABI_VERSION: u32 = [0-9]+;' \ + crates/cpex-ffi/src/lib.rs || true)" +[[ -n "$ABI_LINE" ]] || err "could not find FFI_ABI_VERSION in crates/cpex-ffi/src/lib.rs" +FFI_ABI="$(echo "$ABI_LINE" | sed -E 's/.*= ([0-9]+);.*/\1/')" +[[ "$FFI_ABI" =~ ^[0-9]+$ ]] || err "extracted FFI_ABI is not an integer: $FFI_ABI" + +info "TARGET=$TARGET TUPLE=$TUPLE VERSION=$VERSION FFI_ABI=$FFI_ABI" + +# Build. `cross` swaps in a containerized toolchain with the right +# sysroot/glibc/musl for the target — used for arm and musl from x86_64 +# linux runners. Local host builds use plain cargo. +if [[ "$USE_CROSS" == "1" ]]; then + command -v cross >/dev/null || err "USE_CROSS=1 but cross is not installed" + info "building with cross" + cross build --release --locked --target "$TARGET" -p cpex-ffi +else + info "building with cargo" + cargo build --release --locked --target "$TARGET" -p cpex-ffi +fi + +ARTIFACT_PATH="target/${TARGET}/release/libcpex_ffi.a" +[[ -f "$ARTIFACT_PATH" ]] || err "expected artifact missing: $ARTIFACT_PATH" + +# Stage into a temp dir, tar from there so the archive has no leading +# directory and tools like the download script can `tar xzf` flat into +# any destination. +STAGE_DIR="$(mktemp -d)" +trap 'rm -rf "$STAGE_DIR"' EXIT + +cp "$ARTIFACT_PATH" "$STAGE_DIR/libcpex_ffi.a" +cp LICENSE "$STAGE_DIR/LICENSE" + +GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)" +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +cat > "$STAGE_DIR/VERSION" < "$STAGE_DIR/FFI_ABI" + +mkdir -p "$DIST_DIR" +TARBALL_NAME="cpex-ffi-${VERSION}-${TUPLE}.tar.gz" +TARBALL_PATH="${DIST_DIR}/${TARBALL_NAME}" + +# tar -C ${STAGE_DIR} . produces a flat archive (no leading dir). +# --owner / --group / --mtime would help reproducibility but BSD/GNU +# tar flag divergence makes that finicky; --locked + cargo gives us +# the most important reproducibility guarantee. +tar -czf "$TARBALL_PATH" -C "$STAGE_DIR" . + +# sha256 companion. Recompute on the consumer side as the integrity gate. +# Use coreutils sha256sum if present (linux), shasum -a 256 otherwise (macOS). +if command -v sha256sum >/dev/null; then + (cd "$DIST_DIR" && sha256sum "$TARBALL_NAME" > "${TARBALL_NAME}.sha256") +else + (cd "$DIST_DIR" && shasum -a 256 "$TARBALL_NAME" > "${TARBALL_NAME}.sha256") +fi + +info "wrote $TARBALL_PATH" +info "wrote ${TARBALL_PATH}.sha256" diff --git a/scripts/release/sign-artifact.sh b/scripts/release/sign-artifact.sh new file mode 100755 index 00000000..f306311a --- /dev/null +++ b/scripts/release/sign-artifact.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Location: ./scripts/release/sign-artifact.sh +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# +# Sign every tarball + SHA256SUMS file in DIST_DIR with cosign keyless +# (Sigstore Fulcio + Rekor). Produces a .sig and .crt next to each +# signed file so downstream consumers can verify without fetching keys. +# +# Invoked once by the sign-and-release job in release-ffi.yaml after +# all matrix-built tarballs are downloaded into dist/. Requires the +# workflow to have `id-token: write` so cosign can obtain the GitHub +# Actions OIDC token for keyless signing. +# +# Inputs (env): +# DIST_DIR Optional. Directory containing .tar.gz / SHA256SUMS files +# to sign. Defaults to ./dist. +# +# Outputs: +# For every cpex-ffi-*.tar.gz or cpex-ffi-*-SHA256SUMS in DIST_DIR: +# .sig +# .crt + +set -euo pipefail + +err() { echo "sign-artifact: error: $*" >&2; exit 1; } +info() { echo "sign-artifact: $*"; } + +DIST_DIR="${DIST_DIR:-./dist}" +[[ -d "$DIST_DIR" ]] || err "DIST_DIR does not exist: $DIST_DIR" + +command -v cosign >/dev/null || err "cosign is required (install before running)" + +# Sign tarballs and the aggregate SHA256SUMS bundle (if present). The +# per-tarball .sha256 companions are not signed individually — the +# SHA256SUMS file is the signed integrity manifest. The download +# script verifies the tarball's own signature directly, so the +# per-tarball .sha256 is convenience-only. +shopt -s nullglob +TO_SIGN=( "$DIST_DIR"/cpex-ffi-*.tar.gz "$DIST_DIR"/cpex-ffi-*-SHA256SUMS ) +shopt -u nullglob + +[[ ${#TO_SIGN[@]} -gt 0 ]] || err "no files to sign in $DIST_DIR" + +info "signing ${#TO_SIGN[@]} file(s) with cosign keyless" + +for f in "${TO_SIGN[@]}"; do + [[ -f "$f" ]] || continue + info " signing $(basename "$f")" + # --yes skips the interactive "open browser?" prompt — required for + # CI. The OIDC token is sourced automatically from the GHA env + # (ACTIONS_ID_TOKEN_REQUEST_URL / _TOKEN). --output-* writes the + # detached signature + cert so verifiers don't need Rekor lookups + # for the basics, though Rekor is still queried for transparency. + cosign sign-blob --yes \ + --output-signature "${f}.sig" \ + --output-certificate "${f}.crt" \ + "$f" +done + +info "done; signed ${#TO_SIGN[@]} file(s)" From cb679ab3e6a3c061787f9baa7301a789b35939ec Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Tue, 9 Jun 2026 14:11:05 -0400 Subject: [PATCH 09/11] feat: add cpex-wasm-plugin and cpex-wasm-host crates Introduces the WASM plugin sandbox system: cpex-wasm-plugin (guest-side cdylib targeting wasm32-wasip2) and cpex-wasm-host (host-side runtime using wasmtime with sandbox policy enforcement, resource limits, and network filtering). Signed-off-by: Shriti Priya --- crates/cpex-wasm-host/Cargo.toml | 17 + crates/cpex-wasm-host/Makefile | 17 + crates/cpex-wasm-host/README.md | 245 +++++ crates/cpex-wasm-host/config/config.yaml | 43 + .../examples/wasm_plugin_demo.rs | 196 ++++ crates/cpex-wasm-host/src/conversions.rs | 483 +++++++++ crates/cpex-wasm-host/src/factory.rs | 200 ++++ crates/cpex-wasm-host/src/lib.rs | 9 + crates/cpex-wasm-host/src/policy_loader.rs | 201 ++++ crates/cpex-wasm-host/src/sandbox_manager.rs | 253 +++++ .../tests/test_policy_loader.rs | 107 ++ crates/cpex-wasm-host/wasm/plugin.wasm | Bin 0 -> 604424 bytes crates/cpex-wasm-host/wit/deps/cli.wit | 261 +++++ crates/cpex-wasm-host/wit/deps/clocks.wit | 157 +++ crates/cpex-wasm-host/wit/deps/filesystem.wit | 587 +++++++++++ crates/cpex-wasm-host/wit/deps/http.wit | 733 ++++++++++++++ crates/cpex-wasm-host/wit/deps/io.wit | 331 ++++++ crates/cpex-wasm-host/wit/deps/random.wit | 92 ++ crates/cpex-wasm-host/wit/deps/sockets.wit | 949 ++++++++++++++++++ crates/cpex-wasm-host/wit/world.wit | 231 +++++ crates/cpex-wasm-plugin/Cargo.lock | 787 +++++++++++++++ crates/cpex-wasm-plugin/Cargo.toml | 17 + crates/cpex-wasm-plugin/Makefile | 21 + crates/cpex-wasm-plugin/README.md | 215 ++++ crates/cpex-wasm-plugin/plugin.wasm | Bin 0 -> 604424 bytes crates/cpex-wasm-plugin/src/conversions.rs | 597 +++++++++++ crates/cpex-wasm-plugin/src/lib.rs | 78 ++ crates/cpex-wasm-plugin/wit/deps/cli.wit | 261 +++++ crates/cpex-wasm-plugin/wit/deps/clocks.wit | 157 +++ .../cpex-wasm-plugin/wit/deps/filesystem.wit | 587 +++++++++++ crates/cpex-wasm-plugin/wit/deps/http.wit | 733 ++++++++++++++ crates/cpex-wasm-plugin/wit/deps/io.wit | 331 ++++++ crates/cpex-wasm-plugin/wit/deps/random.wit | 92 ++ crates/cpex-wasm-plugin/wit/deps/sockets.wit | 949 ++++++++++++++++++ crates/cpex-wasm-plugin/wit/world.wit | 231 +++++ 35 files changed, 10168 insertions(+) create mode 100644 crates/cpex-wasm-host/Cargo.toml create mode 100644 crates/cpex-wasm-host/Makefile create mode 100644 crates/cpex-wasm-host/README.md create mode 100644 crates/cpex-wasm-host/config/config.yaml create mode 100644 crates/cpex-wasm-host/examples/wasm_plugin_demo.rs create mode 100644 crates/cpex-wasm-host/src/conversions.rs create mode 100644 crates/cpex-wasm-host/src/factory.rs create mode 100644 crates/cpex-wasm-host/src/lib.rs create mode 100644 crates/cpex-wasm-host/src/policy_loader.rs create mode 100644 crates/cpex-wasm-host/src/sandbox_manager.rs create mode 100644 crates/cpex-wasm-host/tests/test_policy_loader.rs create mode 100644 crates/cpex-wasm-host/wasm/plugin.wasm create mode 100644 crates/cpex-wasm-host/wit/deps/cli.wit create mode 100644 crates/cpex-wasm-host/wit/deps/clocks.wit create mode 100644 crates/cpex-wasm-host/wit/deps/filesystem.wit create mode 100644 crates/cpex-wasm-host/wit/deps/http.wit create mode 100644 crates/cpex-wasm-host/wit/deps/io.wit create mode 100644 crates/cpex-wasm-host/wit/deps/random.wit create mode 100644 crates/cpex-wasm-host/wit/deps/sockets.wit create mode 100644 crates/cpex-wasm-host/wit/world.wit create mode 100644 crates/cpex-wasm-plugin/Cargo.lock create mode 100644 crates/cpex-wasm-plugin/Cargo.toml create mode 100644 crates/cpex-wasm-plugin/Makefile create mode 100644 crates/cpex-wasm-plugin/README.md create mode 100644 crates/cpex-wasm-plugin/plugin.wasm create mode 100644 crates/cpex-wasm-plugin/src/conversions.rs create mode 100644 crates/cpex-wasm-plugin/src/lib.rs create mode 100644 crates/cpex-wasm-plugin/wit/deps/cli.wit create mode 100644 crates/cpex-wasm-plugin/wit/deps/clocks.wit create mode 100644 crates/cpex-wasm-plugin/wit/deps/filesystem.wit create mode 100644 crates/cpex-wasm-plugin/wit/deps/http.wit create mode 100644 crates/cpex-wasm-plugin/wit/deps/io.wit create mode 100644 crates/cpex-wasm-plugin/wit/deps/random.wit create mode 100644 crates/cpex-wasm-plugin/wit/deps/sockets.wit create mode 100644 crates/cpex-wasm-plugin/wit/world.wit diff --git a/crates/cpex-wasm-host/Cargo.toml b/crates/cpex-wasm-host/Cargo.toml new file mode 100644 index 00000000..376d998f --- /dev/null +++ b/crates/cpex-wasm-host/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cpex-wasm-host" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1", features = ["sync", "macros", "io-util", "rt", "rt-multi-thread", "time", "signal", "net"] } +wasmtime = { version = "45.0", features = ["component-model", "async"] } +wasmtime-wasi = "45.0" +wasmtime-wasi-http = { version = "45.0", features = ["default-send-request"] } +hyper = "1" +anyhow = "1.0" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +serde_json = { workspace = true } +cpex-core = { path = "../cpex-core" } +async-trait = "0.1" diff --git a/crates/cpex-wasm-host/Makefile b/crates/cpex-wasm-host/Makefile new file mode 100644 index 00000000..7c4a1c83 --- /dev/null +++ b/crates/cpex-wasm-host/Makefile @@ -0,0 +1,17 @@ +.PHONY: build build-plugin run clean + +PLUGIN_DIR = ../cpex-wasm-plugin +WASM_DIR = wasm + +build-plugin: + $(MAKE) -C $(PLUGIN_DIR) build + +build: build-plugin + cargo build --release + +run: build + cargo run --release + +clean: + cargo clean + rm -f $(WASM_DIR)/plugin.wasm diff --git a/crates/cpex-wasm-host/README.md b/crates/cpex-wasm-host/README.md new file mode 100644 index 00000000..9753358d --- /dev/null +++ b/crates/cpex-wasm-host/README.md @@ -0,0 +1,245 @@ +# cpex-wasm-host + +Loads and executes a WASM plugin inside a sandboxed wasmtime environment. Enforces resource limits (fuel, memory, execution time) and network/filesystem policies. Provides a bridge to cpex-core's `PluginManager` for integration into the hook pipeline. + +## How It Works + +``` +┌──────────────────────────────────────────────────────────┐ +│ PluginManager (cpex-core) │ +│ invoke_named::("cmf.tool_pre_invoke", ...) │ +└──────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ WasmBridgeHandler (factory.rs) │ +│ native MessagePayload → WIT MessagePayload │ +│ native Extensions → WIT Extensions │ +│ native PluginContext → WIT PluginContext │ +└──────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ SandboxManager (sandbox_manager.rs) │ +│ call_handle_hook() inside wasmtime sandbox │ +│ ┌─────────────────────────────────┐ │ +│ │ Sandbox Enforcement │ │ +│ │ • Fuel budget (session-level) │ │ +│ │ • Memory limit │ │ +│ │ • Execution timeout (per-call) │ │ +│ │ • Network allowlist │ │ +│ │ • Filesystem permissions │ │ +│ │ • Environment variable filter │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ WIT PluginResult → native PluginResult │ +│ returned to PluginManager pipeline │ +└──────────────────────────────────────────────────────────┘ +``` + +## Project Structure + +``` +cpex-wasm-host/ +├── Cargo.toml +├── Makefile # Build plugin + host +├── config/ +│ └── config.yaml # Plugin sandbox policy config +├── examples/ +│ └── wasm_plugin_demo.rs # Integration with PluginManager +├── src/ +│ ├── lib.rs # Module exports +│ ├── sandbox_manager.rs # Core: wasmtime engine, plugin loading, invocation +│ ├── policy_loader.rs # Parses sandbox config (filesystem, network, env, resources) +│ ├── conversions.rs # Native cpex-core types ↔ WIT types +│ └── factory.rs # PluginFactory bridge for PluginManager integration +├── wasm/ +│ └── plugin.wasm # Compiled WASM plugin (from cpex-wasm-plugin) +└── wit/ + ├── world.wit # Plugin interface definition + └── deps/ # WASI interface dependencies +``` + +## Components + +### SandboxManager (`sandbox_manager.rs`) + +The core component. Manages a single WASM plugin in an isolated wasmtime environment. + +- `new()` — Creates the wasmtime engine, linker, and epoch ticker thread +- `load_wasmplugin(path, config)` — Instantiates a WASM component with sandbox policies applied +- `invoke(payload, extensions, ctx)` — Calls the plugin's `handle-hook` function +- `is_loaded()` — Checks if a plugin is loaded + +**Sandbox enforcement:** +- Fuel budget is session-level — set once at load, depletes across all invocations +- Execution timeout is per-invocation — reset each call so no single call hangs +- Network requests are gated by an allowlist of hosts +- Filesystem access is limited to preopened directories with explicit permissions +- Only explicitly listed environment variables are visible to the plugin + +### Policy Loader (`policy_loader.rs`) + +Parses sandbox configuration from the plugin's `config.sandbox_policy` YAML key: + +```yaml +plugins: + - name: identity-checker + kind: "wasm://plugin.wasm" + config: + sandbox_policy: + allowed_filesystem: + - dir: /tmp/data + permission: "read" + allowed_network: + - "httpbin.org" + allowed_env: + - "API_KEY" + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 +``` + +If `sandbox_policy` is absent, deny-by-default applies (no filesystem, no network, no env vars). + +### Conversions (`conversions.rs`) + +Bidirectional type mappings between native cpex-core types and WIT types: + +| Direction | Purpose | +|---|---| +| Native → WIT | Before calling the WASM sandbox (payload, extensions, context) | +| WIT → Native | After the sandbox returns (plugin result, modified payload) | + +WIT can't represent `HashMap`, `HashSet`, `Arc`, or `serde_json::Value` directly, so these are serialized to JSON strings or flattened to lists/tuples at the boundary. + +### Factory (`factory.rs`) + +Bridges cpex-core's `PluginFactory` trait to the `SandboxManager`. Contains: + +- `WasmPluginFactory` — implements `PluginFactory::create()`, loads the plugin into the sandbox +- `WasmBridgePlugin` — implements `Plugin` trait (lifecycle) +- `WasmBridgeHandler` — implements `AnyHookHandler`, converts types and routes calls through the sandbox + +## Prerequisites + +- Rust toolchain (stable) +- `wasm32-wasip2` target installed: + ```sh + rustup target add wasm32-wasip2 + ``` +- (Optional) `wasm-tools` for validation/inspection: + ```sh + cargo install wasm-tools + ``` + +## Building End-to-End + +### 1. Build the WASM plugin + +The plugin source lives in `../cpex-wasm-plugin`. Build it and copy the artifact: + +```sh +make build-plugin +``` + +This runs `cargo build --target wasm32-wasip2 --release` in the plugin crate and copies the resulting `plugin.wasm` into `wasm/`. + +### 2. Build the host + +```sh +cargo build --release +``` + +Or build both in one step: + +```sh +make build +``` + +### 3. Run the demo + +```sh +cargo run --example wasm_plugin_demo +``` + +This loads `config/config.yaml`, registers the WASM plugin factory, and invokes the plugin through cpex-core's `PluginManager` pipeline. + +### 4. Run tests + +```sh +cargo test +``` + +## Usage + +### Direct (without PluginManager) + +```rust +use std::path::Path; +use cpex_wasm_host::policy_loader::SandboxPolicy; +use cpex_wasm_host::sandbox_manager::SandboxManager; + +let policy = SandboxPolicy::default(); // deny-all sandbox +let mut manager = SandboxManager::new()?; +manager.load_wasmplugin(Path::new("wasm/plugin.wasm"), Some(&policy)).await?; + +let result = manager.invoke(payload, extensions, ctx).await?; +``` + +### Via PluginManager + +```rust +use std::path::PathBuf; +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_core::manager::PluginManager; +use cpex_core::config::parse_config; + +let mgr = PluginManager::default(); + +mgr.register_factory( + "wasm://plugin.wasm", + Box::new(WasmPluginFactory::new(PathBuf::from("wasm"))), +); + +let config = parse_config(&yaml)?; +mgr.load_config(config)?; +mgr.initialize().await?; + +let (result, bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload, ext, None) + .await; +bg.wait_for_background_tasks().await; +``` + +## Config Format + +The `kind` field uses the `wasm://` scheme following cpex-core's convention. +The `sandbox_policy` is nested under the plugin's `config` key: + +```yaml +plugins: + - name: my-plugin + kind: "wasm://plugin.wasm" + hooks: [cmf.tool_pre_invoke] + capabilities: [read_security] + config: + sandbox_policy: + allowed_filesystem: + - dir: /tmp/data + permission: "read" + allowed_network: ["api.example.com"] + allowed_env: ["API_KEY"] + resources: + max_fuel: 500000000 + max_memory_bytes: 5242880 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 +``` + +If `sandbox_policy` is absent or all lists are empty, deny-by-default applies (no filesystem, no network, no env vars). diff --git a/crates/cpex-wasm-host/config/config.yaml b/crates/cpex-wasm-host/config/config.yaml new file mode 100644 index 00000000..818b488e --- /dev/null +++ b/crates/cpex-wasm-host/config/config.yaml @@ -0,0 +1,43 @@ +# 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] + +plugins: + - name: identity-checker + kind: wasm://plugin.wasm + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: + - read_labels + - read_subject + - read_roles + config: + # Sandbox policy controls what host resources the WASM plugin can access. + # Empty lists = full lockdown (deny-all): no filesystem, no network, no env vars. + # The plugin can only operate on data passed via handle-hook arguments. + sandbox_policy: + allowed_filesystem: [] # No host filesystem access + allowed_network: [] # No outbound HTTP allowed + allowed_env: [] # No host environment variables exposed + resources: + max_memory_bytes: 10485760 # 10 MB linear memory cap + max_fuel: 1000000000 # ~1 billion instructions (session-level budget) + max_execution_time_ms: 5000 # 5 seconds per invocation timeout + max_instances: 10 # Max WASM module instances + max_tables: 10 # Max WASM tables + +routes: + - tool: "*" + plugins: [] diff --git a/crates/cpex-wasm-host/examples/wasm_plugin_demo.rs b/crates/cpex-wasm-host/examples/wasm_plugin_demo.rs new file mode 100644 index 00000000..a61a1104 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_plugin_demo.rs @@ -0,0 +1,196 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_plugin_demo.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Demonstrates invoking a WASM plugin through the PluginManager pipeline. +// Reads plugin configuration from config/config.yaml. + +use std::path::PathBuf; +use std::sync::Arc; + +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::config::parse_config; +use cpex_core::extensions::{HttpExtension, RequestExtension, SecurityExtension}; +use cpex_core::hooks::payload::{Extensions, MetaExtension}; +use cpex_core::extensions::security::SubjectExtension; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; + +#[tokio::main] +async fn main() { + println!("=== WASM Plugin Demo (via PluginManager) ===\n"); + + // 1. Create plugin manager and register wasm factory under the exact kind string. + // Each plugin gets its own isolated SandboxManager instance. + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config.yaml"); + println!("--- Loading config from {} ---\n", config_path.display()); + let yaml = std::fs::read_to_string(&config_path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path.display(), e)); + let cpex_config = parse_config(&yaml).unwrap(); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://plugin.wasm", + Box::new(WasmPluginFactory::new(crate_dir.join("wasm"))), + ); + + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // 3. Build a test payload (assistant requesting a tool call) + 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, + }, + }; + + // 4. Build extensions with security context + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR_DATA"); + security.classification = Some("confidential".into()); + security.subject = Some(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"); + let (pre_result, bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + ext, + None, // first hook — no context table + ) + .await; + + println!(); + + 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; + + 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() + } + }); + + 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-wasm-host/src/conversions.rs b/crates/cpex-wasm-host/src/conversions.rs new file mode 100644 index 00000000..1aea5bf3 --- /dev/null +++ b/crates/cpex-wasm-host/src/conversions.rs @@ -0,0 +1,483 @@ +// Location: ./crates/cpex-wasm-host/src/conversions.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Host-side type conversions: native cpex-core types ↔ WIT types. +// Used by WasmBridgeHandler to translate between the PluginManager's native +// types and the WIT types that the WASM sandbox expects. + +use std::collections::HashMap; + +use cpex_core::cmf::content as native_content; +use cpex_core::cmf::enums as native_enums; +use cpex_core::cmf::message as native_msg; +use cpex_core::context::PluginContext as NativePluginContext; +use cpex_core::error::PluginViolation as NativePluginViolation; +use cpex_core::extensions::container::Extensions as NativeExtensions; +use cpex_core::extensions::http::HttpExtension as NativeHttpExtension; +use cpex_core::extensions::meta::MetaExtension as NativeMetaExtension; +use cpex_core::extensions::request::RequestExtension as NativeRequestExtension; +use cpex_core::extensions::security::{ + SecurityExtension as NativeSecurityExtension, SubjectExtension as NativeSubjectExtension, + SubjectType as NativeSubjectType, +}; +use cpex_core::hooks::trait_def::PluginResult as NativePluginResult; + +use crate::sandbox_manager::types::*; + +// --------------------------------------------------------------------------- +// Native → WIT: MessagePayload +// --------------------------------------------------------------------------- + +/// Converts native cpex-core MessagePayload to WIT MessagePayload for the sandbox. +pub fn native_payload_to_wit(payload: &native_msg::MessagePayload) -> MessagePayload { + MessagePayload { + message: native_message_to_wit(&payload.message), + } +} + +fn native_message_to_wit(msg: &native_msg::Message) -> Message { + Message { + schema_version: msg.schema_version.clone(), + role: native_role_to_wit(msg.role), + content: msg.content.iter().map(native_content_part_to_wit).collect(), + channel: msg.channel.map(native_channel_to_wit), + } +} + +fn native_role_to_wit(role: native_enums::Role) -> Role { + match role { + native_enums::Role::System => Role::System, + native_enums::Role::Developer => Role::Developer, + native_enums::Role::User => Role::User, + native_enums::Role::Assistant => Role::Assistant, + native_enums::Role::Tool => Role::Tool, + } +} + +fn native_channel_to_wit(channel: native_enums::Channel) -> Channel { + match channel { + native_enums::Channel::Analysis => Channel::Analysis, + native_enums::Channel::Commentary => Channel::Commentary, + native_enums::Channel::Final => Channel::Final, + } +} + +fn native_content_part_to_wit(part: &native_content::ContentPart) -> ContentPart { + match part { + native_content::ContentPart::Text { text } => ContentPart::Text(text.clone()), + native_content::ContentPart::Thinking { text } => ContentPart::Thinking(text.clone()), + native_content::ContentPart::ToolCall { content } => { + ContentPart::ToolCall(native_tool_call_to_wit(content)) + } + native_content::ContentPart::ToolResult { content } => { + ContentPart::ToolResult(native_tool_result_to_wit(content)) + } + native_content::ContentPart::Resource { content } => { + ContentPart::CmfResource(native_resource_to_wit(content)) + } + native_content::ContentPart::ResourceRef { content } => { + ContentPart::ResourceRef(native_resource_ref_to_wit(content)) + } + native_content::ContentPart::PromptRequest { content } => { + ContentPart::PromptRequest(native_prompt_request_to_wit(content)) + } + native_content::ContentPart::PromptResult { content } => { + ContentPart::PromptResult(native_prompt_result_to_wit(content)) + } + native_content::ContentPart::Image { content } => ContentPart::Image(ImageSource { + source_type: content.source_type.clone(), + data: content.data.clone(), + media_type: content.media_type.clone(), + }), + native_content::ContentPart::Video { content } => ContentPart::Video(VideoSource { + source_type: content.source_type.clone(), + data: content.data.clone(), + media_type: content.media_type.clone(), + duration_ms: content.duration_ms, + }), + native_content::ContentPart::Audio { content } => ContentPart::Audio(AudioSource { + source_type: content.source_type.clone(), + data: content.data.clone(), + media_type: content.media_type.clone(), + duration_ms: content.duration_ms, + }), + native_content::ContentPart::Document { content } => { + ContentPart::Document(DocumentSource { + source_type: content.source_type.clone(), + data: content.data.clone(), + media_type: content.media_type.clone(), + title: content.title.clone(), + }) + } + } +} + +fn native_tool_call_to_wit(tc: &native_content::ToolCall) -> ToolCall { + ToolCall { + tool_call_id: tc.tool_call_id.clone(), + name: tc.name.clone(), + arguments: serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string()), + namespace: tc.namespace.clone(), + } +} + +fn native_tool_result_to_wit(tr: &native_content::ToolResult) -> ToolResult { + ToolResult { + tool_call_id: tr.tool_call_id.clone(), + tool_name: tr.tool_name.clone(), + content: serde_json::to_string(&tr.content).unwrap_or_default(), + is_error: tr.is_error, + } +} + +fn native_resource_to_wit(r: &native_content::Resource) -> CmfResource { + CmfResource { + resource_request_id: r.resource_request_id.clone(), + uri: r.uri.clone(), + name: r.name.clone(), + description: r.description.clone(), + resource_type: native_resource_type_to_wit(r.resource_type), + content: r.content.clone(), + blob: r.blob.clone(), + mime_type: r.mime_type.clone(), + size_bytes: r.size_bytes, + annotations: serde_json::to_string(&r.annotations).unwrap_or_else(|_| "{}".to_string()), + version: r.version.clone(), + } +} + +fn native_resource_type_to_wit(rt: native_enums::ResourceType) -> ResourceType { + match rt { + native_enums::ResourceType::File => ResourceType::File, + native_enums::ResourceType::Blob => ResourceType::Blob, + native_enums::ResourceType::Uri => ResourceType::Uri, + native_enums::ResourceType::Database => ResourceType::Database, + native_enums::ResourceType::Api => ResourceType::Api, + native_enums::ResourceType::Memory => ResourceType::Memory, + native_enums::ResourceType::Artifact => ResourceType::Artifact, + } +} + +fn native_resource_ref_to_wit(rr: &native_content::ResourceReference) -> ResourceReference { + ResourceReference { + resource_request_id: rr.resource_request_id.clone(), + uri: rr.uri.clone(), + name: rr.name.clone(), + resource_type: native_resource_type_to_wit(rr.resource_type), + range_start: rr.range_start, + range_end: rr.range_end, + selector: rr.selector.clone(), + } +} + +fn native_prompt_request_to_wit(pr: &native_content::PromptRequest) -> PromptRequest { + PromptRequest { + prompt_request_id: pr.prompt_request_id.clone(), + name: pr.name.clone(), + arguments: serde_json::to_string(&pr.arguments).unwrap_or_else(|_| "{}".to_string()), + server_id: pr.server_id.clone(), + } +} + +fn native_prompt_result_to_wit(pr: &native_content::PromptResult) -> PromptResult { + PromptResult { + prompt_request_id: pr.prompt_request_id.clone(), + prompt_name: pr.prompt_name.clone(), + messages: serde_json::to_string(&pr.messages).unwrap_or_else(|_| "[]".to_string()), + content: pr.content.clone(), + is_error: pr.is_error, + error_message: pr.error_message.clone(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: Extensions +// --------------------------------------------------------------------------- + +/// Converts native cpex-core Extensions to WIT Extensions. +/// Only maps fields that the WIT interface supports (request, security, http, meta). +pub fn native_extensions_to_wit(ext: &NativeExtensions) -> Extensions { + Extensions { + request: ext.request.as_ref().map(|r| native_request_to_wit(r)), + security: ext.security.as_ref().map(|s| native_security_to_wit(s)), + http: ext.http.as_ref().map(|h| native_http_to_wit(h)), + meta: ext.meta.as_ref().map(|m| native_meta_to_wit(m)), + } +} + +fn native_request_to_wit(r: &NativeRequestExtension) -> RequestExtension { + RequestExtension { + environment: r.environment.clone(), + request_id: r.request_id.clone(), + timestamp: r.timestamp.clone(), + trace_id: r.trace_id.clone(), + span_id: r.span_id.clone(), + } +} + +fn native_security_to_wit(s: &NativeSecurityExtension) -> SecurityExtension { + SecurityExtension { + labels: s.labels.iter().cloned().collect(), + classification: s.classification.clone(), + subject: s.subject.as_ref().map(native_subject_to_wit), + auth_method: s.auth_method.clone(), + } +} + +fn native_subject_to_wit(s: &NativeSubjectExtension) -> SubjectExtension { + SubjectExtension { + id: s.id.clone(), + subject_type: s.subject_type.as_ref().map(native_subject_type_to_wit), + roles: s.roles.iter().cloned().collect(), + permissions: s.permissions.iter().cloned().collect(), + teams: s.teams.iter().cloned().collect(), + claims: s.claims.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + } +} + +fn native_subject_type_to_wit(st: &NativeSubjectType) -> SubjectType { + match st { + NativeSubjectType::User => SubjectType::User, + NativeSubjectType::Agent => SubjectType::Agent, + NativeSubjectType::Service => SubjectType::Service, + NativeSubjectType::System => SubjectType::System, + } +} + +fn native_http_to_wit(h: &NativeHttpExtension) -> HttpExtension { + HttpExtension { + request_headers: h.request_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + response_headers: h.response_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + } +} + +fn native_meta_to_wit(m: &NativeMetaExtension) -> MetaExtension { + MetaExtension { + entity_type: m.entity_type.clone(), + entity_name: m.entity_name.clone(), + tags: m.tags.iter().cloned().collect(), + scope: m.scope.clone(), + properties: m.properties.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: PluginContext +// --------------------------------------------------------------------------- + +/// Converts native PluginContext (HashMaps) to WIT PluginContext (JSON strings). +pub fn native_context_to_wit(ctx: &NativePluginContext) -> PluginContext { + PluginContext { + local_state: serde_json::to_string(&ctx.local_state).unwrap_or_else(|_| "{}".to_string()), + global_state: serde_json::to_string(&ctx.global_state).unwrap_or_else(|_| "{}".to_string()), + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: PluginResult +// --------------------------------------------------------------------------- + +/// Converts a WIT PluginResult from the sandbox back to native PluginResult. +pub fn wit_result_to_native(result: crate::sandbox_manager::types::PluginResult) -> NativePluginResult { + NativePluginResult { + continue_processing: result.continue_processing, + modified_payload: result.modified_payload.map(wit_payload_to_native), + modified_extensions: None, // WIT modified_extensions would require OwnedExtensions conversion + violation: result.violation.map(wit_violation_to_native), + metadata: result.metadata.and_then(|s| serde_json::from_str(&s).ok()), + } +} + +fn wit_violation_to_native(v: PluginViolation) -> NativePluginViolation { + NativePluginViolation { + code: v.code, + reason: v.reason, + description: v.description, + details: serde_json::from_str(&v.details).unwrap_or_default(), + plugin_name: None, + proto_error_code: v.proto_error_code, + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: MessagePayload (for modified_payload in results) +// --------------------------------------------------------------------------- + +fn wit_payload_to_native(payload: MessagePayload) -> native_msg::MessagePayload { + native_msg::MessagePayload { + message: wit_message_to_native(payload.message), + } +} + +fn wit_message_to_native(msg: Message) -> native_msg::Message { + native_msg::Message { + schema_version: msg.schema_version, + role: wit_role_to_native(msg.role), + content: msg.content.into_iter().map(wit_content_part_to_native).collect(), + channel: msg.channel.map(wit_channel_to_native), + } +} + +fn wit_role_to_native(role: Role) -> native_enums::Role { + match role { + Role::System => native_enums::Role::System, + Role::Developer => native_enums::Role::Developer, + Role::User => native_enums::Role::User, + Role::Assistant => native_enums::Role::Assistant, + Role::Tool => native_enums::Role::Tool, + } +} + +fn wit_channel_to_native(channel: Channel) -> native_enums::Channel { + match channel { + Channel::Analysis => native_enums::Channel::Analysis, + Channel::Commentary => native_enums::Channel::Commentary, + Channel::Final => native_enums::Channel::Final, + } +} + +fn wit_content_part_to_native(part: ContentPart) -> native_content::ContentPart { + match part { + ContentPart::Text(text) => native_content::ContentPart::Text { text }, + ContentPart::Thinking(text) => native_content::ContentPart::Thinking { text }, + ContentPart::ToolCall(tc) => native_content::ContentPart::ToolCall { + content: wit_tool_call_to_native(tc), + }, + ContentPart::ToolResult(tr) => native_content::ContentPart::ToolResult { + content: wit_tool_result_to_native(tr), + }, + ContentPart::CmfResource(r) => native_content::ContentPart::Resource { + content: wit_resource_to_native(r), + }, + ContentPart::ResourceRef(rr) => native_content::ContentPart::ResourceRef { + content: wit_resource_ref_to_native(rr), + }, + ContentPart::PromptRequest(pr) => native_content::ContentPart::PromptRequest { + content: wit_prompt_request_to_native(pr), + }, + ContentPart::PromptResult(pr) => native_content::ContentPart::PromptResult { + content: wit_prompt_result_to_native(pr), + }, + ContentPart::Image(img) => native_content::ContentPart::Image { + content: native_content::ImageSource { + source_type: img.source_type, + data: img.data, + media_type: img.media_type, + }, + }, + ContentPart::Video(v) => native_content::ContentPart::Video { + content: native_content::VideoSource { + source_type: v.source_type, + data: v.data, + media_type: v.media_type, + duration_ms: v.duration_ms, + }, + }, + ContentPart::Audio(a) => native_content::ContentPart::Audio { + content: native_content::AudioSource { + source_type: a.source_type, + data: a.data, + media_type: a.media_type, + duration_ms: a.duration_ms, + }, + }, + ContentPart::Document(d) => native_content::ContentPart::Document { + content: native_content::DocumentSource { + source_type: d.source_type, + data: d.data, + media_type: d.media_type, + title: d.title, + }, + }, + } +} + +fn wit_tool_call_to_native(tc: ToolCall) -> native_content::ToolCall { + let arguments: HashMap = + serde_json::from_str(&tc.arguments).unwrap_or_default(); + native_content::ToolCall { + tool_call_id: tc.tool_call_id, + name: tc.name, + arguments, + namespace: tc.namespace, + } +} + +fn wit_tool_result_to_native(tr: ToolResult) -> native_content::ToolResult { + let content: serde_json::Value = + serde_json::from_str(&tr.content).unwrap_or(serde_json::Value::String(tr.content.clone())); + native_content::ToolResult { + tool_call_id: tr.tool_call_id, + tool_name: tr.tool_name, + content, + is_error: tr.is_error, + } +} + +fn wit_resource_to_native(r: CmfResource) -> native_content::Resource { + let annotations: HashMap = + serde_json::from_str(&r.annotations).unwrap_or_default(); + native_content::Resource { + resource_request_id: r.resource_request_id, + uri: r.uri, + name: r.name, + description: r.description, + resource_type: wit_resource_type_to_native(r.resource_type), + content: r.content, + blob: r.blob, + mime_type: r.mime_type, + size_bytes: r.size_bytes, + annotations, + version: r.version, + } +} + +fn wit_resource_type_to_native(rt: ResourceType) -> native_enums::ResourceType { + match rt { + ResourceType::File => native_enums::ResourceType::File, + ResourceType::Blob => native_enums::ResourceType::Blob, + ResourceType::Uri => native_enums::ResourceType::Uri, + ResourceType::Database => native_enums::ResourceType::Database, + ResourceType::Api => native_enums::ResourceType::Api, + ResourceType::Memory => native_enums::ResourceType::Memory, + ResourceType::Artifact => native_enums::ResourceType::Artifact, + } +} + +fn wit_resource_ref_to_native(rr: ResourceReference) -> native_content::ResourceReference { + native_content::ResourceReference { + resource_request_id: rr.resource_request_id, + uri: rr.uri, + name: rr.name, + resource_type: wit_resource_type_to_native(rr.resource_type), + range_start: rr.range_start, + range_end: rr.range_end, + selector: rr.selector, + } +} + +fn wit_prompt_request_to_native(pr: PromptRequest) -> native_content::PromptRequest { + let arguments: HashMap = + serde_json::from_str(&pr.arguments).unwrap_or_default(); + native_content::PromptRequest { + prompt_request_id: pr.prompt_request_id, + name: pr.name, + arguments, + server_id: pr.server_id, + } +} + +fn wit_prompt_result_to_native(pr: PromptResult) -> native_content::PromptResult { + let messages: Vec = + serde_json::from_str(&pr.messages).unwrap_or_default(); + native_content::PromptResult { + prompt_request_id: pr.prompt_request_id, + prompt_name: pr.prompt_name, + messages, + content: pr.content, + is_error: pr.is_error, + error_message: pr.error_message, + } +} diff --git a/crates/cpex-wasm-host/src/factory.rs b/crates/cpex-wasm-host/src/factory.rs new file mode 100644 index 00000000..e9334410 --- /dev/null +++ b/crates/cpex-wasm-host/src/factory.rs @@ -0,0 +1,200 @@ +// Location: ./crates/cpex-wasm-host/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WasmPluginFactory — bridges cpex-core's PluginFactory trait to the +// SandboxManager. Implements PluginFactory so WASM plugins can be +// registered with the PluginManager and participate in the hook pipeline. +// Each plugin gets its own SandboxManager instance (isolated engine + store). + +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use cpex_core::cmf::message::MessagePayload; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::Extensions; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::payload::PluginPayload; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use crate::conversions::{native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, wit_result_to_native}; +use crate::sandbox_manager::SandboxManager; + +// --------------------------------------------------------------------------- +// WasmPluginFactory +// --------------------------------------------------------------------------- + +/// Factory that creates WASM plugin instances, each with its own SandboxManager. +/// Every plugin gets an isolated wasmtime engine and store — no contention between plugins. +pub struct WasmPluginFactory { + wasm_dir: PathBuf, +} + +impl WasmPluginFactory { + pub fn new(wasm_dir: PathBuf) -> Self { + Self { wasm_dir } + } +} + +impl PluginFactory for WasmPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + // Parse wasm path from kind (e.g., "wasm://plugin.wasm" → "plugin.wasm") + let wasm_filename = config + .kind + .strip_prefix("wasm://") + .ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}': kind '{}' must start with 'wasm://'", + config.name, config.kind + ), + }) + })?; + + let wasm_path = self.wasm_dir.join(wasm_filename); + + // Extract sandbox policy from plugin's config field. + // If absent, deny-by-default applies (no filesystem, no network, no env vars). + let sandbox_policy = config + .config + .as_ref() + .and_then(|v| v.get("sandbox_policy")) + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + + // Create a new SandboxManager for this plugin (isolated engine + store) + let sandbox = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let mut mgr = SandboxManager::new() + .map_err(|e| format!("failed to create sandbox: {}", e))?; + mgr.load_wasmplugin(&wasm_path, sandbox_policy.as_ref()) + .await + .map_err(|e| format!("failed to load WASM: {}", e))?; + Ok::<_, String>(mgr) + }) + }) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!("plugin '{}': {}", config.name, e), + }) + })?; + + let sandbox = Arc::new(Mutex::new(sandbox)); + + let plugin: Arc = Arc::new(WasmBridgePlugin { + config: config.clone(), + }); + + let handler: Arc = Arc::new(WasmBridgeHandler { + plugin_name: config.name.clone(), + sandbox, + }); + + // Register handler for each hook the plugin declares + let hooks: Vec<(&'static str, Arc)> = config + .hooks + .iter() + .map(|hook_name| { + let leaked: &'static str = Box::leak(hook_name.clone().into_boxed_str()); + (leaked, handler.clone()) + }) + .collect(); + + Ok(PluginInstance { + plugin, + handlers: hooks, + }) + } +} + +// --------------------------------------------------------------------------- +// WasmBridgePlugin — lifecycle wrapper +// --------------------------------------------------------------------------- + +/// Implements the Plugin trait for WASM plugins. Handles lifecycle. +struct WasmBridgePlugin { + config: PluginConfig, +} + +#[async_trait] +impl Plugin for WasmBridgePlugin { + fn config(&self) -> &PluginConfig { + &self.config + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// WasmBridgeHandler — hook dispatch through the WASM sandbox +// --------------------------------------------------------------------------- + +/// Implements AnyHookHandler by converting native types to WIT, +/// invoking the WASM sandbox, and converting the result back. +/// Each handler owns its own SandboxManager — no contention with other plugins. +struct WasmBridgeHandler { + plugin_name: String, + sandbox: Arc>, +} + +#[async_trait] +impl AnyHookHandler for WasmBridgeHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + // Downcast the type-erased payload to MessagePayload + let native_payload = payload + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}': payload type mismatch, expected MessagePayload", + self.plugin_name + ), + }) + })?; + + // Convert native types → WIT types + let wit_payload = native_payload_to_wit(native_payload); + let wit_extensions = native_extensions_to_wit(extensions); + let wit_ctx = native_context_to_wit(ctx); + + // Invoke the WASM plugin through its dedicated sandbox + let wit_result = { + let mut mgr = self.sandbox.lock().await; + mgr.invoke(wit_payload, wit_extensions, wit_ctx) + .await + .map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}': WASM invocation failed: {}", + self.plugin_name, e + ), + }) + })? + }; + + // Convert WIT result → native PluginResult, then erase for the executor + let native_result = wit_result_to_native(wit_result); + Ok(cpex_core::executor::erase_result(native_result)) + } + + fn hook_type_name(&self) -> &'static str { + "cmf" + } +} diff --git a/crates/cpex-wasm-host/src/lib.rs b/crates/cpex-wasm-host/src/lib.rs new file mode 100644 index 00000000..974fd41b --- /dev/null +++ b/crates/cpex-wasm-host/src/lib.rs @@ -0,0 +1,9 @@ +// Location: ./crates/cpex-wasm-host/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +pub mod conversions; +pub mod factory; +pub mod policy_loader; +pub mod sandbox_manager; diff --git a/crates/cpex-wasm-host/src/policy_loader.rs b/crates/cpex-wasm-host/src/policy_loader.rs new file mode 100644 index 00000000..17bf70aa --- /dev/null +++ b/crates/cpex-wasm-host/src/policy_loader.rs @@ -0,0 +1,201 @@ +// Location: ./crates/cpex-wasm-host/src/policy_loader.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// PolicyLoader — defines the SandboxPolicy schema and builds a WASI context +// from it. The sandbox policy controls what host resources a WASM plugin can +// access: filesystem paths, network hosts, and environment variables. +// When no policy is provided (or all lists are empty), the plugin runs in a +// fully locked-down sandbox with no access to the outside world. + +use std::{path::Path, sync::Arc}; + +use anyhow::Result; +use serde::Deserialize; +use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder}; +use wasmtime_wasi_http::WasiHttpCtx; + +/// Declarative sandbox policy deserialized from the plugin's config.sandbox_policy YAML key. +/// Controls filesystem, network, and environment access for the WASM plugin. +/// All fields default to empty/deny — a missing or empty policy means full lockdown. +#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)] +pub struct SandboxPolicy { + /// Directories/files the plugin may access (empty = no filesystem access) + #[serde(default)] + pub allowed_filesystem: Vec, + /// Host names the plugin may make outbound HTTP requests to (empty = no network) + #[serde(default)] + pub allowed_network: Vec, + /// Environment variable names the plugin may read from the host (empty = no env access) + #[serde(default)] + pub allowed_env: Vec, + /// Resource limits (memory, fuel, execution time) for the WASM store + #[serde(default)] + pub resources: ResourceLimits, +} + +/// Resource limits enforced on the WASM store. +/// None means unlimited (wasmtime defaults apply). +#[derive(Debug, Clone, Deserialize, serde::Serialize)] +pub struct ResourceLimits { + /// Maximum linear memory the plugin can allocate (bytes) + #[serde(default)] + pub max_memory_bytes: Option, + /// Maximum instructions (fuel units) the plugin can execute across all invocations + #[serde(default)] + pub max_fuel: Option, + /// Maximum wall-clock time for a single invocation (milliseconds) + #[serde(default)] + pub max_execution_time_ms: Option, + /// Maximum number of WASM module instances + #[serde(default)] + pub max_instances: Option, + /// Maximum number of WASM tables + #[serde(default)] + pub max_tables: Option, +} + +impl Default for ResourceLimits { + fn default() -> Self { + Self { + max_memory_bytes: None, + max_fuel: None, + max_execution_time_ms: None, + max_instances: None, + max_tables: None, + } + } +} + +/// A single filesystem access rule — grants access to a directory or file with a permission level. +#[derive(Debug, Clone, Deserialize, serde::Serialize)] +pub struct FilesystemRule { + /// Directory path to preopen into the WASM sandbox + #[serde(default)] + pub dir: Option, + /// File path (its parent directory is preopened) + #[serde(default)] + pub file: Option, + /// Permission level: "read" or "write"/"mutate" + pub permission: String, +} + +/// The constructed WASI + HTTP context ready to be installed into a wasmtime Store. +pub struct PluginWasiContext { + pub wasi_ctx: WasiCtx, + pub http_ctx: WasiHttpCtx, + /// Network allow-list passed to the NetworkPolicy hook for outbound HTTP filtering + pub allowed_hosts: Arc>, +} + +/// Builds a WASI context from the given sandbox policy. +/// Preopens filesystem paths, injects allowed env vars, and captures the network allow-list. +/// If sandbox_policy is None, the context grants no host access (full lockdown). +pub fn build_wasi_context(sandbox_policy: Option<&SandboxPolicy>) -> Result { + let mut builder = WasiCtxBuilder::new(); + + if let Some(policy) = sandbox_policy { + for rule in &policy.allowed_filesystem { + let (dir_perms, file_perms) = match rule.permission.as_str() { + "read" => (DirPerms::READ, FilePerms::READ), + "write" | "mutate" => (DirPerms::READ | DirPerms::MUTATE, FilePerms::READ | FilePerms::WRITE), + other => anyhow::bail!("unknown filesystem permission: {}", other), + }; + + if let Some(dir) = &rule.dir { + builder + .preopened_dir(dir, dir, dir_perms, file_perms) + .map_err(|e| anyhow::anyhow!("failed to preopen dir '{}': {}", dir, e))?; + } else if let Some(file) = &rule.file { + let parent = Path::new(file) + .parent() + .ok_or_else(|| anyhow::anyhow!("file '{}' has no parent directory", file))?; + builder + .preopened_dir(parent, parent.to_string_lossy().as_ref(), dir_perms, file_perms) + .map_err(|e| anyhow::anyhow!("failed to preopen parent dir for file '{}': {}", file, e))?; + } + } + + for key in &policy.allowed_env { + if let Ok(val) = std::env::var(key) { + builder.env(key, &val); + } + } + } + + builder.inherit_stdio(); + + let wasi_ctx = builder.build(); + let http_ctx = WasiHttpCtx::new(); + let allowed_hosts = Arc::new( + sandbox_policy + .map(|p| p.allowed_network.clone()) + .unwrap_or_default(), + ); + + Ok(PluginWasiContext { + wasi_ctx, + http_ctx, + allowed_hosts, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_parse_sandbox_policy_from_config_file() { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = fs::read_to_string(&config_path) + .expect("failed to read config file"); + let config: serde_yaml::Value = serde_yaml::from_str(&raw) + .expect("failed to parse YAML"); + + let sandbox_policy_value = config["plugins"][0]["config"]["sandbox_policy"].clone(); + let policy: SandboxPolicy = serde_yaml::from_value(sandbox_policy_value) + .expect("failed to deserialize sandbox_policy"); + + assert!(policy.allowed_filesystem.is_empty()); + assert!(policy.allowed_network.is_empty()); + assert!(policy.allowed_env.is_empty()); + assert_eq!(policy.resources.max_memory_bytes, Some(10485760)); + assert_eq!(policy.resources.max_fuel, Some(1000000000)); + assert_eq!(policy.resources.max_execution_time_ms, Some(5000)); + assert_eq!(policy.resources.max_instances, Some(10)); + assert_eq!(policy.resources.max_tables, Some(10)); + } + + #[test] + fn test_deserialize_sandbox_policy() { + let yaml = r#" +allowed_filesystem: + - dir: /tmp/data + permission: "read" +allowed_network: + - "httpbin.org" +allowed_env: + - "API_KEY" +resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 +"#; + let policy: SandboxPolicy = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(policy.allowed_network, vec!["httpbin.org"]); + assert_eq!(policy.allowed_env, vec!["API_KEY"]); + assert_eq!(policy.allowed_filesystem.len(), 1); + assert_eq!(policy.resources.max_memory_bytes, Some(10485760)); + assert_eq!(policy.resources.max_fuel, Some(1000000000)); + } + + #[test] + fn test_default_sandbox_policy_denies_all() { + let policy = SandboxPolicy::default(); + assert!(policy.allowed_filesystem.is_empty()); + assert!(policy.allowed_network.is_empty()); + assert!(policy.allowed_env.is_empty()); + assert!(policy.resources.max_memory_bytes.is_none()); + } +} diff --git a/crates/cpex-wasm-host/src/sandbox_manager.rs b/crates/cpex-wasm-host/src/sandbox_manager.rs new file mode 100644 index 00000000..a011e508 --- /dev/null +++ b/crates/cpex-wasm-host/src/sandbox_manager.rs @@ -0,0 +1,253 @@ +// Location: ./crates/cpex-wasm-host/src/sandbox_manager.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// SandboxManager — loads and invokes a single WASM plugin in a wasmtime sandbox. +// Enforces resource limits (fuel, memory, execution time) and network policy. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use wasmtime::component::{Component, Linker, ResourceTable}; +use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder}; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; +use wasmtime_wasi_http::WasiHttpCtx; +use wasmtime_wasi_http::p2::{WasiHttpCtxView, WasiHttpView}; +use wasmtime_wasi_http::p2::body::HyperOutgoingBody; +use wasmtime_wasi_http::p2::types::{HostFutureIncomingResponse, OutgoingRequestConfig}; +use wasmtime_wasi_http::p2::{HttpResult, WasiHttpHooks, default_send_request}; +use wasmtime_wasi_http::p2::bindings::http::types::ErrorCode; + +use crate::policy_loader::{build_wasi_context, SandboxPolicy, ResourceLimits}; + +// Generate Rust bindings from the WIT interface definition. +// This creates the `Plugin` struct with `call_handle_hook` and the WIT types. +wasmtime::component::bindgen!({ + path: "wit", + world: "plugin", + exports: { default: async }, +}); + +/// Re-export WIT-generated types for use by the factory and conversions modules. +pub mod types { + pub use super::cpex::plugin::types::*; +} + +/// Intercepts outbound HTTP requests from the WASM plugin and enforces the network allow-list. +/// Only requests to explicitly allowed hosts (or their subdomains) are permitted. +struct NetworkPolicy { + allowed_hosts: Arc>, +} + +impl WasiHttpHooks for NetworkPolicy { + fn send_request( + &mut self, + request: hyper::Request, + config: OutgoingRequestConfig, + ) -> HttpResult { + // Extract the target host from the request URI + let authority = request + .uri() + .authority() + .map(|a| a.host().to_string()) + .unwrap_or_default(); + + // Check exact match or subdomain match (e.g., "api.example.com" matches "example.com") + let is_allowed = self.allowed_hosts.iter().any(|allowed| { + authority == *allowed || authority.ends_with(&format!(".{}", allowed)) + }); + + if !is_allowed { + return Err(ErrorCode::HttpRequestDenied.into()); + } + + Ok(default_send_request(request, config)) + } +} + +/// Per-plugin state held in the wasmtime Store. +/// Contains WASI context, HTTP context, network policy, resource table, and store limits. +struct WasmPluginState { + wasi: WasiCtx, + http: WasiHttpCtx, + network: NetworkPolicy, + table: ResourceTable, + limits: StoreLimits, +} + +impl WasiView for WasmPluginState { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { + ctx: &mut self.wasi, + table: &mut self.table, + } + } +} + +impl WasiHttpView for WasmPluginState { + fn http(&mut self) -> WasiHttpCtxView<'_> { + WasiHttpCtxView { + ctx: &mut self.http, + table: &mut self.table, + hooks: &mut self.network, + } + } +} + +/// A loaded and instantiated WASM plugin, ready for invocation. +struct WasmPluginInstance { + store: Store, + plugin: Plugin, + /// Epoch ticks before the store traps (used to reset timeout per invocation) + epoch_deadline: u64, +} + +/// Manages a single WASM plugin in a sandboxed wasmtime environment. +/// Enforces resource limits (fuel, memory, execution time) and network policy. +pub struct SandboxManager { + engine: Engine, + linker: Linker, + instance: Option, +} + +impl SandboxManager { + /// Create a new SandboxManager with no plugin loaded. + pub fn new() -> Result { + let mut config = Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + config.epoch_interruption(true); + let engine = Engine::new(&config)?; + + let mut linker = Linker::new(&engine); + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; + + // Start epoch ticker thread for timeout enforcement + let engine_clone = engine.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(std::time::Duration::from_millis(1)); + engine_clone.increment_epoch(); + }); + + Ok(Self { + engine, + linker, + instance: None, + }) + } + + /// Load a plugin from a WASM file with the given sandbox policy. + /// Replaces any previously loaded plugin. + pub async fn load_wasmplugin( + &mut self, + wasm_path: &Path, + sandbox_policy: Option<&SandboxPolicy>, + ) -> Result<()> { + eprintln!("[SANDBOX] load_wasmplugin: path={}", wasm_path.display()); + let ctx = build_wasi_context(sandbox_policy)?; + eprintln!("[SANDBOX] WASI context built"); + let default_resources = ResourceLimits::default(); + let resources = sandbox_policy + .map(|p| &p.resources) + .unwrap_or(&default_resources); + + // Build store limits from resource config + let mut limits_builder = StoreLimitsBuilder::new(); + if let Some(max_mem) = resources.max_memory_bytes { + limits_builder = limits_builder.memory_size(max_mem); + } + if let Some(max_instances) = resources.max_instances { + limits_builder = limits_builder.instances(max_instances); + } + if let Some(max_tables) = resources.max_tables { + limits_builder = limits_builder.tables(max_tables); + } + let limits = limits_builder.trap_on_grow_failure(true).build(); + + eprintln!("[SANDBOX] Loading component from file..."); + let component = Component::from_file(&self.engine, wasm_path) + .map_err(|e| anyhow::anyhow!("failed to load wasm from {}: {}", wasm_path.display(), e))?; + eprintln!("[SANDBOX] Component loaded successfully"); + + let mut store = Store::new( + &self.engine, + WasmPluginState { + wasi: ctx.wasi_ctx, + http: ctx.http_ctx, + network: NetworkPolicy { + allowed_hosts: ctx.allowed_hosts, + }, + table: ResourceTable::new(), + limits, + }, + ); + + // Apply memory/table limits + store.limiter(|state| &mut state.limits); + + // Apply fuel budget (instruction count limit) + let fuel = resources.max_fuel.unwrap_or(u64::MAX); + store.set_fuel(fuel) + .map_err(|e| anyhow::anyhow!("failed to set fuel: {}", e))?; + + // Apply execution timeout via epoch deadline + // Each epoch tick is ~1ms (from ticker thread), so ms ≈ ticks + let epoch_deadline = resources.max_execution_time_ms.unwrap_or(3_600_000); + store.set_epoch_deadline(epoch_deadline); + store.epoch_deadline_trap(); + + eprintln!("[SANDBOX] Instantiating plugin component..."); + let plugin = Plugin::instantiate_async(&mut store, &component, &self.linker) + .await + .map_err(|e| anyhow::anyhow!("failed to instantiate plugin: {}", e))?; + eprintln!("[SANDBOX] Plugin instantiated OK"); + + self.instance = Some(WasmPluginInstance { + store, + plugin, + epoch_deadline, + }); + + Ok(()) + } + + /// Invoke the loaded plugin's handle-hook function. + /// Fuel is a session-level budget (not reset between invocations). + /// Epoch deadline is per-invocation (reset each call so no single call hangs). + pub async fn invoke( + &mut self, + payload: types::MessagePayload, + extensions: types::Extensions, + ctx: types::PluginContext, + ) -> Result { + eprintln!("[SANDBOX] invoke() called, plugin loaded: {}", self.is_loaded()); + let instance = self + .instance + .as_mut() + .with_context(|| "no plugin loaded")?; + + // Reset epoch deadline per invocation (timeout is per-call) + instance.store.set_epoch_deadline(instance.epoch_deadline); + + eprintln!("[SANDBOX] Calling handle_hook on WASM component..."); + let result = instance + .plugin + .call_handle_hook(&mut instance.store, &payload, &extensions, &ctx) + .await; + + match &result { + Ok(r) => eprintln!("[SANDBOX] handle_hook OK: continue_processing={}", r.continue_processing), + Err(e) => eprintln!("[SANDBOX] handle_hook FAILED: {}", e), + } + + result.map_err(|e| anyhow::anyhow!("plugin invocation failed: {}", e)) + } + + /// Returns whether a plugin is currently loaded. + pub fn is_loaded(&self) -> bool { + self.instance.is_some() + } +} diff --git a/crates/cpex-wasm-host/tests/test_policy_loader.rs b/crates/cpex-wasm-host/tests/test_policy_loader.rs new file mode 100644 index 00000000..e96aa8d1 --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_policy_loader.rs @@ -0,0 +1,107 @@ +// Location: ./crates/cpex-wasm-host/tests/test_policy_loader.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Integration tests for policy_loader. +// Validates that the sandbox policy in config/config.yaml deserializes correctly +// and matches the expected deny-all posture with resource limits. + +use std::path::Path; + +use cpex_wasm_host::policy_loader::SandboxPolicy; + +/// Helper: reads config.yaml and extracts the sandbox_policy for a named plugin. +fn load_sandbox_policy_from_config(plugin_name: &str) -> SandboxPolicy { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = std::fs::read_to_string(&config_path).expect("failed to read config.yaml"); + let config: serde_yaml::Value = serde_yaml::from_str(&raw).expect("failed to parse YAML"); + + let plugin = config["plugins"] + .as_sequence() + .expect("plugins should be a list") + .iter() + .find(|p| p["name"].as_str() == Some(plugin_name)) + .unwrap_or_else(|| panic!("plugin '{}' not found in config", plugin_name)); + + let sandbox_value = plugin["config"]["sandbox_policy"].clone(); + serde_yaml::from_value(sandbox_value).expect("failed to deserialize sandbox_policy") +} + +#[test] +fn config_file_is_valid_yaml() { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = std::fs::read_to_string(&config_path).expect("failed to read config.yaml"); + let _: serde_yaml::Value = serde_yaml::from_str(&raw).expect("config.yaml is not valid YAML"); +} + +#[test] +fn config_has_plugins_list() { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = std::fs::read_to_string(&config_path).expect("failed to read config.yaml"); + let config: serde_yaml::Value = serde_yaml::from_str(&raw).unwrap(); + + let plugins = config["plugins"].as_sequence().expect("plugins should be a list"); + assert!(!plugins.is_empty(), "plugins list should not be empty"); +} + +#[test] +fn identity_checker_plugin_exists_with_correct_kind() { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = std::fs::read_to_string(&config_path).expect("failed to read config.yaml"); + let config: serde_yaml::Value = serde_yaml::from_str(&raw).unwrap(); + + let plugin = config["plugins"] + .as_sequence() + .unwrap() + .iter() + .find(|p| p["name"].as_str() == Some("identity-checker")) + .expect("identity-checker plugin not found"); + + assert_eq!(plugin["kind"].as_str(), Some("wasm://plugin.wasm")); +} + +#[test] +fn sandbox_policy_allowed_network_is_empty() { + let policy = load_sandbox_policy_from_config("identity-checker"); + assert!(policy.allowed_network.is_empty()); +} + +#[test] +fn sandbox_policy_allowed_env_is_empty() { + let policy = load_sandbox_policy_from_config("identity-checker"); + assert!(policy.allowed_env.is_empty()); +} + +#[test] +fn sandbox_policy_allowed_filesystem_is_empty() { + let policy = load_sandbox_policy_from_config("identity-checker"); + assert!(policy.allowed_filesystem.is_empty()); +} + +#[test] +fn sandbox_policy_resource_limits() { + let policy = load_sandbox_policy_from_config("identity-checker"); + + assert_eq!(policy.resources.max_memory_bytes, Some(10_485_760)); + assert_eq!(policy.resources.max_fuel, Some(1_000_000_000)); + assert_eq!(policy.resources.max_execution_time_ms, Some(5000)); + assert_eq!(policy.resources.max_instances, Some(10)); + assert_eq!(policy.resources.max_tables, Some(10)); +} + +#[test] +fn sandbox_policy_deserializes_to_same_type_used_by_factory() { + let policy = load_sandbox_policy_from_config("identity-checker"); + let json = serde_json::to_value(&policy).expect("SandboxPolicy should serialize to JSON"); + + let roundtripped: SandboxPolicy = + serde_json::from_value(json).expect("SandboxPolicy should roundtrip through JSON"); + + assert_eq!(roundtripped.allowed_network, policy.allowed_network); + assert_eq!(roundtripped.allowed_env, policy.allowed_env); + assert_eq!( + roundtripped.resources.max_memory_bytes, + policy.resources.max_memory_bytes + ); +} diff --git a/crates/cpex-wasm-host/wasm/plugin.wasm b/crates/cpex-wasm-host/wasm/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..55472f0bc32b560e3fbc1b61ccc94b25d0a5c14c GIT binary patch literal 604424 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 z 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%0DUZEF z*olq4AmZ5;k5C3QYWD&pM`k(_NSQ#FMv2W@IEtM?i;iA&Bd zQ;LPJnZ*R>UiKU|zDmO{okceAxpiqa@?T3c&I*NrVp%YOacZhO^m_tzerhqZEiQ#v zC~ht$_rL%FSKAu=*wz?3wQ)x*EwrZ%{t?aiYRC#$=#ZAShtw^VEaWE@28@A1=Etc~ z?sN2n&8`32sEd-?;nqnBS7kC`i_N~Mi}}6`Be2$}-ASH2rZ|}+mPUuSc�!XR5%& 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 list>; + + /// Get the POSIX-style arguments to the program. + @since(version = 0.2.0) + get-arguments: func() -> list; + + /// Return a path that programs should use as their initial current working + /// directory, interpreting `.` as shorthand for this. + @since(version = 0.2.0) + initial-cwd: func() -> option; +} + +@since(version = 0.2.0) +interface exit { + /// Exit the current instance and any linked instances. + @since(version = 0.2.0) + exit: func(status: result); + + /// Exit the current instance and any linked instances, reporting the + /// specified status code to the host. + /// + /// The meaning of the code depends on the context, with 0 usually meaning + /// "success", and other values indicating various types of failure. + /// + /// This function does not return; the effect is analogous to a trap, but + /// without the connotation that something bad has happened. + @unstable(feature = cli-exit-with-code) + exit-with-code: func(status-code: u8); +} + +@since(version = 0.2.0) +interface run { + /// Run the program. + @since(version = 0.2.0) + run: func() -> result; +} + +@since(version = 0.2.0) +interface stdin { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream}; + + @since(version = 0.2.0) + get-stdin: func() -> input-stream; +} + +@since(version = 0.2.0) +interface stdout { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{output-stream}; + + @since(version = 0.2.0) + get-stdout: func() -> output-stream; +} + +@since(version = 0.2.0) +interface stderr { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{output-stream}; + + @since(version = 0.2.0) + get-stderr: func() -> output-stream; +} + +/// Terminal input. +/// +/// In the future, this may include functions for disabling echoing, +/// disabling input buffering so that keyboard events are sent through +/// immediately, querying supported features, and so on. +@since(version = 0.2.0) +interface terminal-input { + /// The input side of a terminal. + @since(version = 0.2.0) + resource terminal-input; +} + +/// Terminal output. +/// +/// In the future, this may include functions for querying the terminal +/// size, being notified of terminal size changes, querying supported +/// features, and so on. +@since(version = 0.2.0) +interface terminal-output { + /// The output side of a terminal. + @since(version = 0.2.0) + resource terminal-output; +} + +/// An interface providing an optional `terminal-input` for stdin as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdin { + @since(version = 0.2.0) + use terminal-input.{terminal-input}; + + /// If stdin is connected to a terminal, return a `terminal-input` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdin: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stdout as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdout { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stdout is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdout: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stderr as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stderr { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stderr is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stderr: func() -> option; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.6; +} +@since(version = 0.2.0) +world command { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.6; + + @since(version = 0.2.0) + export run; +} diff --git a/crates/cpex-wasm-host/wit/deps/clocks.wit b/crates/cpex-wasm-host/wit/deps/clocks.wit new file mode 100644 index 00000000..d638f1a4 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/clocks.wit @@ -0,0 +1,157 @@ +package wasi:clocks@0.2.6; + +/// WASI Monotonic Clock is a clock API intended to let users measure elapsed +/// time. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A monotonic clock is a clock which has an unspecified initial value, and +/// successive reads of the clock will produce non-decreasing values. +@since(version = 0.2.0) +interface monotonic-clock { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + + /// An instant in time, in nanoseconds. An instant is relative to an + /// unspecified initial value, and can only be compared to instances from + /// the same monotonic-clock. + @since(version = 0.2.0) + type instant = u64; + + /// A duration of time, in nanoseconds. + @since(version = 0.2.0) + type duration = u64; + + /// Read the current value of the clock. + /// + /// The clock is monotonic, therefore calling this function repeatedly will + /// produce a sequence of non-decreasing values. + @since(version = 0.2.0) + now: func() -> instant; + + /// Query the resolution of the clock. Returns the duration of time + /// corresponding to a clock tick. + @since(version = 0.2.0) + resolution: func() -> duration; + + /// Create a `pollable` which will resolve once the specified instant + /// has occurred. + @since(version = 0.2.0) + subscribe-instant: func(when: instant) -> pollable; + + /// Create a `pollable` that will resolve after the specified duration has + /// elapsed from the time this function is invoked. + @since(version = 0.2.0) + subscribe-duration: func(when: duration) -> pollable; +} + +/// WASI Wall Clock is a clock API intended to let users query the current +/// time. The name "wall" makes an analogy to a "clock on the wall", which +/// is not necessarily monotonic as it may be reset. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A wall clock is a clock which measures the date and time according to +/// some external reference. +/// +/// External references may be reset, so this clock is not necessarily +/// monotonic, making it unsuitable for measuring elapsed time. +/// +/// It is intended for reporting the current date and time for humans. +@since(version = 0.2.0) +interface wall-clock { + /// A time and date in seconds plus nanoseconds. + @since(version = 0.2.0) + record datetime { + seconds: u64, + nanoseconds: u32, + } + + /// Read the current value of the clock. + /// + /// This clock is not monotonic, therefore calling this function repeatedly + /// will not necessarily produce a sequence of non-decreasing values. + /// + /// The returned timestamps represent the number of seconds since + /// 1970-01-01T00:00:00Z, also known as [POSIX's Seconds Since the Epoch], + /// also known as [Unix Time]. + /// + /// The nanoseconds field of the output is always less than 1000000000. + /// + /// [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 + /// [Unix Time]: https://en.wikipedia.org/wiki/Unix_time + @since(version = 0.2.0) + now: func() -> datetime; + + /// Query the resolution of the clock. + /// + /// The nanoseconds field of the output is always less than 1000000000. + @since(version = 0.2.0) + resolution: func() -> datetime; +} + +@unstable(feature = clocks-timezone) +interface timezone { + @unstable(feature = clocks-timezone) + use wall-clock.{datetime}; + + /// Information useful for displaying the timezone of a specific `datetime`. + /// + /// This information may vary within a single `timezone` to reflect daylight + /// saving time adjustments. + @unstable(feature = clocks-timezone) + record timezone-display { + /// The number of seconds difference between UTC time and the local + /// time of the timezone. + /// + /// The returned value will always be less than 86400 which is the + /// number of seconds in a day (24*60*60). + /// + /// In implementations that do not expose an actual time zone, this + /// should return 0. + utc-offset: s32, + /// The abbreviated name of the timezone to display to a user. The name + /// `UTC` indicates Coordinated Universal Time. Otherwise, this should + /// reference local standards for the name of the time zone. + /// + /// In implementations that do not expose an actual time zone, this + /// should be the string `UTC`. + /// + /// In time zones that do not have an applicable name, a formatted + /// representation of the UTC offset may be returned, such as `-04:00`. + name: string, + /// Whether daylight saving time is active. + /// + /// In implementations that do not expose an actual time zone, this + /// should return false. + in-daylight-saving-time: bool, + } + + /// Return information needed to display the given `datetime`. This includes + /// the UTC offset, the time zone name, and a flag indicating whether + /// daylight saving time is active. + /// + /// If the timezone cannot be determined for the given `datetime`, return a + /// `timezone-display` for `UTC` with a `utc-offset` of 0 and no daylight + /// saving time. + @unstable(feature = clocks-timezone) + display: func(when: datetime) -> timezone-display; + + /// The same as `display`, but only return the UTC offset. + @unstable(feature = clocks-timezone) + utc-offset: func(when: datetime) -> s32; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import monotonic-clock; + @since(version = 0.2.0) + import wall-clock; + @unstable(feature = clocks-timezone) + import timezone; +} diff --git a/crates/cpex-wasm-host/wit/deps/filesystem.wit b/crates/cpex-wasm-host/wit/deps/filesystem.wit new file mode 100644 index 00000000..9f4a8288 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/filesystem.wit @@ -0,0 +1,587 @@ +package wasi:filesystem@0.2.6; + +/// WASI filesystem is a filesystem API primarily intended to let users run WASI +/// programs that access their files on their existing filesystems, without +/// significant overhead. +/// +/// It is intended to be roughly portable between Unix-family platforms and +/// Windows, though it does not hide many of the major differences. +/// +/// Paths are passed as interface-type `string`s, meaning they must consist of +/// a sequence of Unicode Scalar Values (USVs). Some filesystems may contain +/// paths which are not accessible by this API. +/// +/// The directory separator in WASI is always the forward-slash (`/`). +/// +/// All paths in WASI are relative paths, and are interpreted relative to a +/// `descriptor` referring to a base directory. If a `path` argument to any WASI +/// function starts with `/`, or if any step of resolving a `path`, including +/// `..` and symbolic link steps, reaches a directory outside of the base +/// directory, or reaches a symlink to an absolute or rooted path in the +/// underlying filesystem, the function fails with `error-code::not-permitted`. +/// +/// For more information about WASI path resolution and sandboxing, see +/// [WASI filesystem path resolution]. +/// +/// [WASI filesystem path resolution]: https://github.com/WebAssembly/wasi-filesystem/blob/main/path-resolution.md +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream, error}; + @since(version = 0.2.0) + use wasi:clocks/wall-clock@0.2.6.{datetime}; + + /// File size or length of a region within a file. + @since(version = 0.2.0) + type filesize = u64; + + /// The type of a filesystem object referenced by a descriptor. + /// + /// Note: This was called `filetype` in earlier versions of WASI. + @since(version = 0.2.0) + enum descriptor-type { + /// The type of the descriptor or file is unknown or is different from + /// any of the other types specified. + unknown, + /// The descriptor refers to a block device inode. + block-device, + /// The descriptor refers to a character device inode. + character-device, + /// The descriptor refers to a directory inode. + directory, + /// The descriptor refers to a named pipe. + fifo, + /// The file refers to a symbolic link inode. + symbolic-link, + /// The descriptor refers to a regular file inode. + regular-file, + /// The descriptor refers to a socket. + socket, + } + + /// Descriptor flags. + /// + /// Note: This was called `fdflags` in earlier versions of WASI. + @since(version = 0.2.0) + flags descriptor-flags { + /// Read mode: Data can be read. + read, + /// Write mode: Data can be written to. + write, + /// Request that writes be performed according to synchronized I/O file + /// integrity completion. The data stored in the file and the file's + /// metadata are synchronized. This is similar to `O_SYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + file-integrity-sync, + /// Request that writes be performed according to synchronized I/O data + /// integrity completion. Only the data stored in the file is + /// synchronized. This is similar to `O_DSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + data-integrity-sync, + /// Requests that reads be performed at the same level of integrity + /// requested for writes. This is similar to `O_RSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + requested-write-sync, + /// Mutating directories mode: Directory contents may be mutated. + /// + /// When this flag is unset on a descriptor, operations using the + /// descriptor which would create, rename, delete, modify the data or + /// metadata of filesystem objects, or obtain another handle which + /// would permit any of those, shall fail with `error-code::read-only` if + /// they would otherwise succeed. + /// + /// This may only be set on directories. + mutate-directory, + } + + /// Flags determining the method of how paths are resolved. + @since(version = 0.2.0) + flags path-flags { + /// As long as the resolved path corresponds to a symbolic link, it is + /// expanded. + symlink-follow, + } + + /// Open flags used by `open-at`. + @since(version = 0.2.0) + flags open-flags { + /// Create file if it does not exist, similar to `O_CREAT` in POSIX. + create, + /// Fail if not a directory, similar to `O_DIRECTORY` in POSIX. + directory, + /// Fail if file already exists, similar to `O_EXCL` in POSIX. + exclusive, + /// Truncate file to size 0, similar to `O_TRUNC` in POSIX. + truncate, + } + + /// Number of hard links to an inode. + @since(version = 0.2.0) + type link-count = u64; + + /// File attributes. + /// + /// Note: This was called `filestat` in earlier versions of WASI. + @since(version = 0.2.0) + record descriptor-stat { + /// File type. + %type: descriptor-type, + /// Number of hard links to the file. + link-count: link-count, + /// For regular files, the file size in bytes. For symbolic links, the + /// length in bytes of the pathname contained in the symbolic link. + size: filesize, + /// Last data access timestamp. + /// + /// If the `option` is none, the platform doesn't maintain an access + /// timestamp for this file. + data-access-timestamp: option, + /// Last data modification timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// modification timestamp for this file. + data-modification-timestamp: option, + /// Last file status-change timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// status-change timestamp for this file. + status-change-timestamp: option, + } + + /// When setting a timestamp, this gives the value to set it to. + @since(version = 0.2.0) + variant new-timestamp { + /// Leave the timestamp set to its previous value. + no-change, + /// Set the timestamp to the current time of the system clock associated + /// with the filesystem. + now, + /// Set the timestamp to the given value. + timestamp(datetime), + } + + /// A directory entry. + record directory-entry { + /// The type of the file referred to by this directory entry. + %type: descriptor-type, + /// The name of the object. + name: string, + } + + /// Error codes returned by functions, similar to `errno` in POSIX. + /// Not all of these error codes are returned by the functions provided by this + /// API; some are used in higher-level library layers, and others are provided + /// merely for alignment with POSIX. + enum error-code { + /// Permission denied, similar to `EACCES` in POSIX. + access, + /// Resource unavailable, or operation would block, similar to `EAGAIN` and `EWOULDBLOCK` in POSIX. + would-block, + /// Connection already in progress, similar to `EALREADY` in POSIX. + already, + /// Bad descriptor, similar to `EBADF` in POSIX. + bad-descriptor, + /// Device or resource busy, similar to `EBUSY` in POSIX. + busy, + /// Resource deadlock would occur, similar to `EDEADLK` in POSIX. + deadlock, + /// Storage quota exceeded, similar to `EDQUOT` in POSIX. + quota, + /// File exists, similar to `EEXIST` in POSIX. + exist, + /// File too large, similar to `EFBIG` in POSIX. + file-too-large, + /// Illegal byte sequence, similar to `EILSEQ` in POSIX. + illegal-byte-sequence, + /// Operation in progress, similar to `EINPROGRESS` in POSIX. + in-progress, + /// Interrupted function, similar to `EINTR` in POSIX. + interrupted, + /// Invalid argument, similar to `EINVAL` in POSIX. + invalid, + /// I/O error, similar to `EIO` in POSIX. + io, + /// Is a directory, similar to `EISDIR` in POSIX. + is-directory, + /// Too many levels of symbolic links, similar to `ELOOP` in POSIX. + loop, + /// Too many links, similar to `EMLINK` in POSIX. + too-many-links, + /// Message too large, similar to `EMSGSIZE` in POSIX. + message-size, + /// Filename too long, similar to `ENAMETOOLONG` in POSIX. + name-too-long, + /// No such device, similar to `ENODEV` in POSIX. + no-device, + /// No such file or directory, similar to `ENOENT` in POSIX. + no-entry, + /// No locks available, similar to `ENOLCK` in POSIX. + no-lock, + /// Not enough space, similar to `ENOMEM` in POSIX. + insufficient-memory, + /// No space left on device, similar to `ENOSPC` in POSIX. + insufficient-space, + /// Not a directory or a symbolic link to a directory, similar to `ENOTDIR` in POSIX. + not-directory, + /// Directory not empty, similar to `ENOTEMPTY` in POSIX. + not-empty, + /// State not recoverable, similar to `ENOTRECOVERABLE` in POSIX. + not-recoverable, + /// Not supported, similar to `ENOTSUP` and `ENOSYS` in POSIX. + unsupported, + /// Inappropriate I/O control operation, similar to `ENOTTY` in POSIX. + no-tty, + /// No such device or address, similar to `ENXIO` in POSIX. + no-such-device, + /// Value too large to be stored in data type, similar to `EOVERFLOW` in POSIX. + overflow, + /// Operation not permitted, similar to `EPERM` in POSIX. + not-permitted, + /// Broken pipe, similar to `EPIPE` in POSIX. + pipe, + /// Read-only file system, similar to `EROFS` in POSIX. + read-only, + /// Invalid seek, similar to `ESPIPE` in POSIX. + invalid-seek, + /// Text file busy, similar to `ETXTBSY` in POSIX. + text-file-busy, + /// Cross-device link, similar to `EXDEV` in POSIX. + cross-device, + } + + /// File or memory access pattern advisory information. + @since(version = 0.2.0) + enum advice { + /// The application has no advice to give on its behavior with respect + /// to the specified data. + normal, + /// The application expects to access the specified data sequentially + /// from lower offsets to higher offsets. + sequential, + /// The application expects to access the specified data in a random + /// order. + random, + /// The application expects to access the specified data in the near + /// future. + will-need, + /// The application expects that it will not access the specified data + /// in the near future. + dont-need, + /// The application expects to access the specified data once and then + /// not reuse it thereafter. + no-reuse, + } + + /// A 128-bit hash value, split into parts because wasm doesn't have a + /// 128-bit integer type. + @since(version = 0.2.0) + record metadata-hash-value { + /// 64 bits of a 128-bit hash value. + lower: u64, + /// Another 64 bits of a 128-bit hash value. + upper: u64, + } + + /// A descriptor is a reference to a filesystem object, which may be a file, + /// directory, named pipe, special file, or other object on which filesystem + /// calls may be made. + @since(version = 0.2.0) + resource descriptor { + /// Return a stream for reading from a file, if available. + /// + /// May fail with an error-code describing why the file cannot be read. + /// + /// Multiple read, write, and append streams may be active on the same open + /// file and they do not interfere with each other. + /// + /// Note: This allows using `read-stream`, which is similar to `read` in POSIX. + @since(version = 0.2.0) + read-via-stream: func(offset: filesize) -> result; + /// Return a stream for writing to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be written. + /// + /// Note: This allows using `write-stream`, which is similar to `write` in + /// POSIX. + @since(version = 0.2.0) + write-via-stream: func(offset: filesize) -> result; + /// Return a stream for appending to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be appended. + /// + /// Note: This allows using `write-stream`, which is similar to `write` with + /// `O_APPEND` in POSIX. + @since(version = 0.2.0) + append-via-stream: func() -> result; + /// Provide file advisory information on a descriptor. + /// + /// This is similar to `posix_fadvise` in POSIX. + @since(version = 0.2.0) + advise: func(offset: filesize, length: filesize, advice: advice) -> result<_, error-code>; + /// Synchronize the data of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fdatasync` in POSIX. + @since(version = 0.2.0) + sync-data: func() -> result<_, error-code>; + /// Get flags associated with a descriptor. + /// + /// Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX. + /// + /// Note: This returns the value that was the `fs_flags` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-flags: func() -> result; + /// Get the dynamic type of a descriptor. + /// + /// Note: This returns the same value as the `type` field of the `fd-stat` + /// returned by `stat`, `stat-at` and similar. + /// + /// Note: This returns similar flags to the `st_mode & S_IFMT` value provided + /// by `fstat` in POSIX. + /// + /// Note: This returns the value that was the `fs_filetype` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-type: func() -> result; + /// Adjust the size of an open file. If this increases the file's size, the + /// extra bytes are filled with zeros. + /// + /// Note: This was called `fd_filestat_set_size` in earlier versions of WASI. + @since(version = 0.2.0) + set-size: func(size: filesize) -> result<_, error-code>; + /// Adjust the timestamps of an open file or directory. + /// + /// Note: This is similar to `futimens` in POSIX. + /// + /// Note: This was called `fd_filestat_set_times` in earlier versions of WASI. + @since(version = 0.2.0) + set-times: func(data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Read from a descriptor, without using and updating the descriptor's offset. + /// + /// This function returns a list of bytes containing the data that was + /// read, along with a bool which, when true, indicates that the end of the + /// file was reached. The returned list will contain up to `length` bytes; it + /// may return fewer than requested, if the end of the file is reached or + /// if the I/O operation is interrupted. + /// + /// In the future, this may change to return a `stream`. + /// + /// Note: This is similar to `pread` in POSIX. + @since(version = 0.2.0) + read: func(length: filesize, offset: filesize) -> result, bool>, error-code>; + /// Write to a descriptor, without using and updating the descriptor's offset. + /// + /// It is valid to write past the end of a file; the file is extended to the + /// extent of the write, with bytes between the previous end and the start of + /// the write set to zero. + /// + /// In the future, this may change to take a `stream`. + /// + /// Note: This is similar to `pwrite` in POSIX. + @since(version = 0.2.0) + write: func(buffer: list, offset: filesize) -> result; + /// Read directory entries from a directory. + /// + /// On filesystems where directories contain entries referring to themselves + /// and their parents, often named `.` and `..` respectively, these entries + /// are omitted. + /// + /// This always returns a new stream which starts at the beginning of the + /// directory. Multiple streams may be active on the same directory, and they + /// do not interfere with each other. + @since(version = 0.2.0) + read-directory: func() -> result; + /// Synchronize the data and metadata of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fsync` in POSIX. + @since(version = 0.2.0) + sync: func() -> result<_, error-code>; + /// Create a directory. + /// + /// Note: This is similar to `mkdirat` in POSIX. + @since(version = 0.2.0) + create-directory-at: func(path: string) -> result<_, error-code>; + /// Return the attributes of an open file or directory. + /// + /// Note: This is similar to `fstat` in POSIX, except that it does not return + /// device and inode information. For testing whether two descriptors refer to + /// the same underlying filesystem object, use `is-same-object`. To obtain + /// additional data that can be used do determine whether a file has been + /// modified, use `metadata-hash`. + /// + /// Note: This was called `fd_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat: func() -> result; + /// Return the attributes of a file or directory. + /// + /// Note: This is similar to `fstatat` in POSIX, except that it does not + /// return device and inode information. See the `stat` description for a + /// discussion of alternatives. + /// + /// Note: This was called `path_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat-at: func(path-flags: path-flags, path: string) -> result; + /// Adjust the timestamps of a file or directory. + /// + /// Note: This is similar to `utimensat` in POSIX. + /// + /// Note: This was called `path_filestat_set_times` in earlier versions of + /// WASI. + @since(version = 0.2.0) + set-times-at: func(path-flags: path-flags, path: string, data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Create a hard link. + /// + /// Fails with `error-code::no-entry` if the old path does not exist, + /// with `error-code::exist` if the new path already exists, and + /// `error-code::not-permitted` if the old path is not a file. + /// + /// Note: This is similar to `linkat` in POSIX. + @since(version = 0.2.0) + link-at: func(old-path-flags: path-flags, old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Open a file or directory. + /// + /// If `flags` contains `descriptor-flags::mutate-directory`, and the base + /// descriptor doesn't have `descriptor-flags::mutate-directory` set, + /// `open-at` fails with `error-code::read-only`. + /// + /// If `flags` contains `write` or `mutate-directory`, or `open-flags` + /// contains `truncate` or `create`, and the base descriptor doesn't have + /// `descriptor-flags::mutate-directory` set, `open-at` fails with + /// `error-code::read-only`. + /// + /// Note: This is similar to `openat` in POSIX. + @since(version = 0.2.0) + open-at: func(path-flags: path-flags, path: string, open-flags: open-flags, %flags: descriptor-flags) -> result; + /// Read the contents of a symbolic link. + /// + /// If the contents contain an absolute or rooted path in the underlying + /// filesystem, this function fails with `error-code::not-permitted`. + /// + /// Note: This is similar to `readlinkat` in POSIX. + @since(version = 0.2.0) + readlink-at: func(path: string) -> result; + /// Remove a directory. + /// + /// Return `error-code::not-empty` if the directory is not empty. + /// + /// Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX. + @since(version = 0.2.0) + remove-directory-at: func(path: string) -> result<_, error-code>; + /// Rename a filesystem object. + /// + /// Note: This is similar to `renameat` in POSIX. + @since(version = 0.2.0) + rename-at: func(old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Create a symbolic link (also known as a "symlink"). + /// + /// If `old-path` starts with `/`, the function fails with + /// `error-code::not-permitted`. + /// + /// Note: This is similar to `symlinkat` in POSIX. + @since(version = 0.2.0) + symlink-at: func(old-path: string, new-path: string) -> result<_, error-code>; + /// Unlink a filesystem object that is not a directory. + /// + /// Return `error-code::is-directory` if the path refers to a directory. + /// Note: This is similar to `unlinkat(fd, path, 0)` in POSIX. + @since(version = 0.2.0) + unlink-file-at: func(path: string) -> result<_, error-code>; + /// Test whether two descriptors refer to the same filesystem object. + /// + /// In POSIX, this corresponds to testing whether the two descriptors have the + /// same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers. + /// wasi-filesystem does not expose device and inode numbers, so this function + /// may be used instead. + @since(version = 0.2.0) + is-same-object: func(other: borrow) -> bool; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a descriptor. + /// + /// This returns a hash of the last-modification timestamp and file size, and + /// may also include the inode number, device number, birth timestamp, and + /// other metadata fields that may change when the file is modified or + /// replaced. It may also include a secret value chosen by the + /// implementation and not otherwise exposed. + /// + /// Implementations are encouraged to provide the following properties: + /// + /// - If the file is not modified or replaced, the computed hash value should + /// usually not change. + /// - If the object is modified or replaced, the computed hash value should + /// usually change. + /// - The inputs to the hash should not be easily computable from the + /// computed hash. + /// + /// However, none of these is required. + @since(version = 0.2.0) + metadata-hash: func() -> result; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a directory descriptor and a relative path. + /// + /// This performs the same hash computation as `metadata-hash`. + @since(version = 0.2.0) + metadata-hash-at: func(path-flags: path-flags, path: string) -> result; + } + + /// A stream of directory entries. + @since(version = 0.2.0) + resource directory-entry-stream { + /// Read a single directory entry from a `directory-entry-stream`. + @since(version = 0.2.0) + read-directory-entry: func() -> result, error-code>; + } + + /// Attempts to extract a filesystem-related `error-code` from the stream + /// `error` provided. + /// + /// Stream operations which return `stream-error::last-operation-failed` + /// have a payload with more information about the operation that failed. + /// This payload can be passed through to this function to see if there's + /// filesystem-related information about the error to return. + /// + /// Note that this function is fallible because not all stream-related + /// errors are filesystem-related errors. + @since(version = 0.2.0) + filesystem-error-code: func(err: borrow) -> option; +} + +@since(version = 0.2.0) +interface preopens { + @since(version = 0.2.0) + use types.{descriptor}; + + /// Return the set of preopened directories, and their paths. + @since(version = 0.2.0) + get-directories: func() -> list>; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import preopens; +} diff --git a/crates/cpex-wasm-host/wit/deps/http.wit b/crates/cpex-wasm-host/wit/deps/http.wit new file mode 100644 index 00000000..eb1b25f0 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/http.wit @@ -0,0 +1,733 @@ +package wasi:http@0.2.6; + +/// This interface defines all of the types and methods for implementing +/// HTTP Requests and Responses, both incoming and outgoing, as well as +/// their headers, trailers, and bodies. +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.6.{duration}; + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/error@0.2.6.{error as io-error}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + + /// This type corresponds to HTTP standard Methods. + @since(version = 0.2.0) + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string), + } + + /// This type corresponds to HTTP standard Related Schemes. + @since(version = 0.2.0) + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + @since(version = 0.2.0) + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.2.0) + record TLS-alert-received-payload { + alert-id: option, + alert-message: option, + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + @since(version = 0.2.0) + record field-size-payload { + field-name: option, + field-size: option, + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// + @since(version = 0.2.0) + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + configuration-error, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. It also includes an optional string for an + /// unstructured description of the error. Users should not depend on the + /// string for diagnosing errors, as it's not required to be consistent + /// between implementations. + internal-error(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + @since(version = 0.2.0) + variant header-error { + /// This error indicates that a `field-name` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + /// This error indicates that a forbidden `field-name` was used when trying + /// to set a header in a `fields`. + forbidden, + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + } + + /// Field keys are always strings. + /// + /// Field keys should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + /// + /// # Deprecation + /// + /// This type has been deprecated in favor of the `field-name` type. + @since(version = 0.2.0) + @deprecated(version = 0.2.2) + type field-key = string; + + /// Field names are always strings. + /// + /// Field names should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + @since(version = 0.2.1) + type field-name = field-key; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + @since(version = 0.2.0) + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `incoming-request.headers`, `outgoing-request.headers`) might be + /// immutable. In an immutable fields, the `set`, `append`, and `delete` + /// operations will fail with `header-error.immutable`. + @since(version = 0.2.0) + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + @since(version = 0.2.0) + constructor(); + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The tuple is a pair of the field name, represented as a string, and + /// Value, represented as a list of bytes. + /// + /// An error result will be returned if any `field-name` or `field-value` is + /// syntactically invalid, or if a field is forbidden. + @since(version = 0.2.0) + from-list: static func(entries: list>) -> result; + /// Get all of the values corresponding to a name. If the name is not present + /// in this `fields` or is syntactically invalid, an empty list is returned. + /// However, if the name is present but empty, this is represented by a list + /// with one or more empty field-values present. + @since(version = 0.2.0) + get: func(name: field-name) -> list; + /// Returns `true` when the name is present in this `fields`. If the name is + /// syntactically invalid, `false` is returned. + @since(version = 0.2.0) + has: func(name: field-name) -> bool; + /// Set all of the values for a name. Clears any existing values for that + /// name, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or any of + /// the `field-value`s are syntactically invalid. + @since(version = 0.2.0) + set: func(name: field-name, value: list) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` is + /// syntactically invalid. + @since(version = 0.2.0) + delete: func(name: field-name) -> result<_, header-error>; + /// Append a value for a name. Does not change or delete any existing + /// values for that name. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or + /// `field-value` are syntactically invalid. + @since(version = 0.2.0) + append: func(name: field-name, value: field-value) -> result<_, header-error>; + /// Retrieve the full set of names and values in the Fields. Like the + /// constructor, the list represents each name-value pair. + /// + /// The outer list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The names and values are always returned in the original casing and in + /// the order in which they will be serialized for transport. + @since(version = 0.2.0) + entries: func() -> list>; + /// Make a deep copy of the Fields. Equivalent in behavior to calling the + /// `fields` constructor on the return value of `entries`. The resulting + /// `fields` is mutable. + @since(version = 0.2.0) + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + @since(version = 0.2.0) + type headers = fields; + + /// Trailers is an alias for Fields. + @since(version = 0.2.0) + type trailers = fields; + + /// Represents an incoming HTTP Request. + @since(version = 0.2.0) + resource incoming-request { + /// Returns the method of the incoming request. + @since(version = 0.2.0) + method: func() -> method; + /// Returns the path with query parameters from the request, as a string. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Returns the protocol scheme from the request. + @since(version = 0.2.0) + scheme: func() -> option; + /// Returns the authority of the Request's target URI, if present. + @since(version = 0.2.0) + authority: func() -> option; + /// Get the `headers` associated with the request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// The `headers` returned are a child resource: it must be dropped before + /// the parent `incoming-request` is dropped. Dropping this + /// `incoming-request` before all children are dropped will trap. + @since(version = 0.2.0) + headers: func() -> headers; + /// Gives the `incoming-body` associated with this request. Will only + /// return success at most once, and subsequent calls will return error. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an outgoing HTTP Request. + @since(version = 0.2.0) + resource outgoing-request { + /// Construct a new `outgoing-request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// * `headers` is the HTTP Headers for the Request. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, an `outgoing-request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `outgoing-handler.handle` implementation + /// to reject invalid constructions of `outgoing-request`. + @since(version = 0.2.0) + constructor(headers: headers); + /// Returns the resource corresponding to the outgoing Body for this + /// Request. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-request` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + /// Get the Method for the Request. + @since(version = 0.2.0) + method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + @since(version = 0.2.0) + set-method: func(method: method) -> result; + /// Get the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + @since(version = 0.2.0) + set-path-with-query: func(path-with-query: option) -> result; + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + @since(version = 0.2.0) + scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + @since(version = 0.2.0) + set-scheme: func(scheme: option) -> result; + /// Get the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. + @since(version = 0.2.0) + authority: func() -> option; + /// Set the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid URI authority. + @since(version = 0.2.0) + set-authority: func(authority: option) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound a + /// blocking call to `wasi:io/poll.poll`. + @since(version = 0.2.0) + resource request-options { + /// Construct a default `request-options` value. + @since(version = 0.2.0) + constructor(); + /// The timeout for the initial connect to the HTTP Server. + @since(version = 0.2.0) + connect-timeout: func() -> option; + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-connect-timeout: func(duration: option) -> result; + /// The timeout for receiving the first byte of the Response body. + @since(version = 0.2.0) + first-byte-timeout: func() -> option; + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-first-byte-timeout: func(duration: option) -> result; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + @since(version = 0.2.0) + between-bytes-timeout: func() -> option; + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported. + @since(version = 0.2.0) + set-between-bytes-timeout: func(duration: option) -> result; + } + + /// Represents the ability to send an HTTP Response. + /// + /// This resource is used by the `wasi:http/incoming-handler` interface to + /// allow a Response to be sent corresponding to the Request provided as the + /// other argument to `incoming-handler.handle`. + @since(version = 0.2.0) + resource response-outparam { + /// Send an HTTP 1xx response. + /// + /// Unlike `response-outparam.set`, this does not consume the + /// `response-outparam`, allowing the guest to send an arbitrary number of + /// informational responses before sending the final response using + /// `response-outparam.set`. + /// + /// This will return an `HTTP-protocol-error` if `status` is not in the + /// range [100-199], or an `internal-error` if the implementation does not + /// support informational responses. + @unstable(feature = informational-outbound-responses) + send-informational: func(status: u16, headers: headers) -> result<_, error-code>; + /// Set the value of the `response-outparam` to either send a response, + /// or indicate an error. + /// + /// This method consumes the `response-outparam` to ensure that it is + /// called at most once. If it is never called, the implementation + /// will respond with an error. + /// + /// The user may provide an `error` to `response` to allow the + /// implementation determine how to respond with an HTTP error response. + @since(version = 0.2.0) + set: static func(param: response-outparam, response: result); + } + + /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.2.0) + type status-code = u16; + + /// Represents an incoming HTTP Response. + @since(version = 0.2.0) + resource incoming-response { + /// Returns the status code from the incoming response. + @since(version = 0.2.0) + status: func() -> status-code; + /// Returns the headers from the incoming response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `incoming-response` is dropped. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the incoming body. May be called at most once. Returns error + /// if called additional times. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an incoming HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, indicating that the full contents of the + /// body have been received. This resource represents the contents as + /// an `input-stream` and the delivery of trailers as a `future-trailers`, + /// and ensures that the user of this interface may only be consuming either + /// the body contents or waiting on trailers at any given time. + @since(version = 0.2.0) + resource incoming-body { + /// Returns the contents of the body, as a stream of bytes. + /// + /// Returns success on first call: the stream representing the contents + /// can be retrieved at most once. Subsequent calls will return error. + /// + /// The returned `input-stream` resource is a child: it must be dropped + /// before the parent `incoming-body` is dropped, or consumed by + /// `incoming-body.finish`. + /// + /// This invariant ensures that the implementation can determine whether + /// the user is consuming the contents of the body, waiting on the + /// `future-trailers` to be ready, or neither. This allows for network + /// backpressure is to be applied when the user is consuming the body, + /// and for that backpressure to not inhibit delivery of the trailers if + /// the user does not read the entire body. + @since(version = 0.2.0) + %stream: func() -> result; + /// Takes ownership of `incoming-body`, and returns a `future-trailers`. + /// This function will trap if the `input-stream` child is still alive. + @since(version = 0.2.0) + finish: static func(this: incoming-body) -> future-trailers; + } + + /// Represents a future which may eventually return trailers, or an error. + /// + /// In the case that the incoming HTTP Request or Response did not have any + /// trailers, this future will resolve to the empty set of trailers once the + /// complete Request or Response body has been received. + @since(version = 0.2.0) + resource future-trailers { + /// Returns a pollable which becomes ready when either the trailers have + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the contents of the trailers, or an error which occurred, + /// once the future is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the trailers or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the HTTP Request or Response + /// body, as well as any trailers, were received successfully, or that an + /// error occurred receiving them. The optional `trailers` indicates whether + /// or not trailers were present in the body. + /// + /// When some `trailers` are returned by this method, the `trailers` + /// resource is immutable, and a child. Use of the `set`, `append`, or + /// `delete` methods will return an error, and the resource must be + /// dropped before the parent `future-trailers` is dropped. + @since(version = 0.2.0) + get: func() -> option, error-code>>>; + } + + /// Represents an outgoing HTTP Response. + @since(version = 0.2.0) + resource outgoing-response { + /// Construct an `outgoing-response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// * `headers` is the HTTP Headers for the Response. + @since(version = 0.2.0) + constructor(headers: headers); + /// Get the HTTP Status Code for the Response. + @since(version = 0.2.0) + status-code: func() -> status-code; + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + @since(version = 0.2.0) + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the resource corresponding to the outgoing Body for this Response. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-response` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + } + + /// Represents an outgoing HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, inducating the full contents of the body + /// have been sent. This resource represents the contents as an + /// `output-stream` child resource, and the completion of the body (with + /// optional trailers) with a static function that consumes the + /// `outgoing-body` resource, and ensures that the user of this interface + /// may not write to the body contents after the body has been finished. + /// + /// If the user code drops this resource, as opposed to calling the static + /// method `finish`, the implementation should treat the body as incomplete, + /// and that an error has occurred. The implementation should propagate this + /// error to the HTTP protocol by whatever means it has available, + /// including: corrupting the body on the wire, aborting the associated + /// Request, or sending a late status code for the Response. + @since(version = 0.2.0) + resource outgoing-body { + /// Returns a stream for writing the body contents. + /// + /// The returned `output-stream` is a child resource: it must be dropped + /// before the parent `outgoing-body` resource is dropped (or finished), + /// otherwise the `outgoing-body` drop or `finish` will trap. + /// + /// Returns success on the first call: the `output-stream` resource for + /// this `outgoing-body` may be retrieved at most once. Subsequent calls + /// will return error. + @since(version = 0.2.0) + write: func() -> result; + /// Finalize an outgoing body, optionally providing trailers. This must be + /// called to signal that the response is complete. If the `outgoing-body` + /// is dropped without calling `outgoing-body.finalize`, the implementation + /// should treat the body as corrupted. + /// + /// Fails if the body's `outgoing-request` or `outgoing-response` was + /// constructed with a Content-Length header, and the contents written + /// to the body (via `write`) does not match the value given in the + /// Content-Length. + @since(version = 0.2.0) + finish: static func(this: outgoing-body, trailers: option) -> result<_, error-code>; + } + + /// Represents a future which may eventually return an incoming HTTP + /// Response, or an error. + /// + /// This resource is returned by the `wasi:http/outgoing-handler` interface to + /// provide the HTTP Response corresponding to the sent Request. + @since(version = 0.2.0) + resource future-incoming-response { + /// Returns a pollable which becomes ready when either the Response has + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the incoming HTTP Response, or an error, once one is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the response or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the incoming HTTP Response + /// status and headers have received successfully, or that an error + /// occurred. Errors may also occur while consuming the response body, + /// but those will be reported by the `incoming-body` and its + /// `output-stream` child. + @since(version = 0.2.0) + get: func() -> option>>; + } + + /// Attempts to extract a http-related `error` from the wasi:io `error` + /// provided. + /// + /// Stream operations which return + /// `wasi:io/stream/stream-error::last-operation-failed` have a payload of + /// type `wasi:io/error/error` with more information about the operation + /// that failed. This payload can be passed through to this function to see + /// if there's http-related information about the error to return. + /// + /// Note that this function is fallible because not all io-errors are + /// http-related errors. + @since(version = 0.2.0) + http-error-code: func(err: borrow) -> option; +} + +/// This interface defines a handler of incoming HTTP Requests. It should +/// be exported by components which can respond to HTTP Requests. +@since(version = 0.2.0) +interface incoming-handler { + @since(version = 0.2.0) + use types.{incoming-request, response-outparam}; + + /// This function is invoked with an incoming HTTP Request, and a resource + /// `response-outparam` which provides the capability to reply with an HTTP + /// Response. The response is sent by calling the `response-outparam.set` + /// method, which allows execution to continue after the response has been + /// sent. This enables both streaming to the response body, and performing other + /// work. + /// + /// The implementor of this function must write a response to the + /// `response-outparam` before returning, or else the caller will respond + /// with an error on its behalf. + @since(version = 0.2.0) + handle: func(request: incoming-request, response-out: response-outparam); +} + +/// This interface defines a handler of outgoing HTTP Requests. It should be +/// imported by components which wish to make HTTP Requests. +@since(version = 0.2.0) +interface outgoing-handler { + @since(version = 0.2.0) + use types.{outgoing-request, request-options, future-incoming-response, error-code}; + + /// This function is invoked with an outgoing HTTP Request, and it returns + /// a resource `future-incoming-response` which represents an HTTP Response + /// which may arrive in the future. + /// + /// The `options` argument accepts optional parameters for the HTTP + /// protocol's transport layer. + /// + /// This function may return an error if the `outgoing-request` is invalid + /// or not allowed to be made. Otherwise, protocol errors are reported + /// through the `future-incoming-response`. + @since(version = 0.2.0) + handle: func(request: outgoing-request, options: option) -> result; +} + +/// The `wasi:http/imports` world imports all the APIs for HTTP proxies. +/// It is intended to be `include`d in other worlds. +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; +} +/// The `wasi:http/proxy` world captures a widely-implementable intersection of +/// hosts that includes HTTP forward and reverse proxies. Components targeting +/// this world may concurrently stream in and out any number of incoming and +/// outgoing HTTP requests. +@since(version = 0.2.0) +world proxy { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; + + @since(version = 0.2.0) + export incoming-handler; +} diff --git a/crates/cpex-wasm-host/wit/deps/io.wit b/crates/cpex-wasm-host/wit/deps/io.wit new file mode 100644 index 00000000..08ad78e6 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/io.wit @@ -0,0 +1,331 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) + to-debug-string: func() -> string; + } +} + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) + resource pollable { + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) + ready: func() -> bool; + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + @since(version = 0.2.0) + block: func(); + } + + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) + poll: func(in: list>) -> list; +} + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) +interface streams { + @since(version = 0.2.0) + use error.{error}; + @since(version = 0.2.0) + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed, + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) + read: func(len: u64) -> result, stream-error>; + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) + blocking-read: func(len: u64) -> result, stream-error>; + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) + skip: func(len: u64) -> result; + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) + blocking-skip: func(len: u64) -> result; + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) + check-write: func() -> result; + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) + write: func(contents: list) -> result<_, stream-error>; + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write`, and `flush`, and is implemented with the + /// following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while !contents.is_empty() { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, contents.len()); + /// let (chunk, rest) = contents.split_at(len); + /// this.write(chunk ); // eliding error handling + /// contents = rest; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) + flush: func() -> result<_, stream-error>; + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) + blocking-flush: func() -> result<_, stream-error>; + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) + write-zeroes: func(len: u64) -> result<_, stream-error>; + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with + /// the following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while num_zeroes != 0 { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, num_zeroes); + /// this.write-zeroes(len); // eliding error handling + /// num_zeroes -= len; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) + splice: func(src: borrow, len: u64) -> result; + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) + blocking-splice: func(src: borrow, len: u64) -> result; + } +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import error; + @since(version = 0.2.0) + import poll; + @since(version = 0.2.0) + import streams; +} diff --git a/crates/cpex-wasm-host/wit/deps/random.wit b/crates/cpex-wasm-host/wit/deps/random.wit new file mode 100644 index 00000000..73edf5b6 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/random.wit @@ -0,0 +1,92 @@ +package wasi:random@0.2.6; + +/// The insecure-seed interface for seeding hash-map DoS resistance. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure-seed { + /// Return a 128-bit value that may contain a pseudo-random value. + /// + /// The returned value is not required to be computed from a CSPRNG, and may + /// even be entirely deterministic. Host implementations are encouraged to + /// provide pseudo-random values to any program exposed to + /// attacker-controlled content, to enable DoS protection built into many + /// languages' hash-map implementations. + /// + /// This function is intended to only be called once, by a source language + /// to initialize Denial Of Service (DoS) protection in its hash-map + /// implementation. + /// + /// # Expected future evolution + /// + /// This will likely be changed to a value import, to prevent it from being + /// called multiple times and potentially used for purposes other than DoS + /// protection. + @since(version = 0.2.0) + insecure-seed: func() -> tuple; +} + +/// The insecure interface for insecure pseudo-random numbers. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure { + /// Return `len` insecure pseudo-random bytes. + /// + /// This function is not cryptographically secure. Do not use it for + /// anything related to security. + /// + /// There are no requirements on the values of the returned bytes, however + /// implementations are encouraged to return evenly distributed values with + /// a long period. + @since(version = 0.2.0) + get-insecure-random-bytes: func(len: u64) -> list; + + /// Return an insecure pseudo-random `u64` value. + /// + /// This function returns the same type of pseudo-random data as + /// `get-insecure-random-bytes`, represented as a `u64`. + @since(version = 0.2.0) + get-insecure-random-u64: func() -> u64; +} + +/// WASI Random is a random data API. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface random { + /// Return `len` cryptographically-secure random or pseudo-random bytes. + /// + /// This function must produce data at least as cryptographically secure and + /// fast as an adequately seeded cryptographically-secure pseudo-random + /// number generator (CSPRNG). It must not block, from the perspective of + /// the calling program, under any circumstances, including on the first + /// request and on requests for numbers of bytes. The returned data must + /// always be unpredictable. + /// + /// This function must always return fresh data. Deterministic environments + /// must omit this function, rather than implementing it with deterministic + /// data. + @since(version = 0.2.0) + get-random-bytes: func(len: u64) -> list; + + /// Return a cryptographically-secure random or pseudo-random `u64` value. + /// + /// This function returns the same type of data as `get-random-bytes`, + /// represented as a `u64`. + @since(version = 0.2.0) + get-random-u64: func() -> u64; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import random; + @since(version = 0.2.0) + import insecure; + @since(version = 0.2.0) + import insecure-seed; +} diff --git a/crates/cpex-wasm-host/wit/deps/sockets.wit b/crates/cpex-wasm-host/wit/deps/sockets.wit new file mode 100644 index 00000000..db6d1a23 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/sockets.wit @@ -0,0 +1,949 @@ +package wasi:sockets@0.2.6; + +@since(version = 0.2.0) +interface network { + @unstable(feature = network-error-code) + use wasi:io/error@0.2.6.{error}; + + /// An opaque resource that represents access to (a subset of) the network. + /// This enables context-based security for networking. + /// There is no need for this to map 1:1 to a physical network interface. + @since(version = 0.2.0) + resource network; + + /// Error codes. + /// + /// In theory, every API can return any error code. + /// In practice, API's typically only return the errors documented per API + /// combined with a couple of errors that are always possible: + /// - `unknown` + /// - `access-denied` + /// - `not-supported` + /// - `out-of-memory` + /// - `concurrency-conflict` + /// + /// See each individual API for what the POSIX equivalents are. They sometimes differ per API. + @since(version = 0.2.0) + enum error-code { + /// Unknown error + unknown, + /// Access denied. + /// + /// POSIX equivalent: EACCES, EPERM + access-denied, + /// The operation is not supported. + /// + /// POSIX equivalent: EOPNOTSUPP + not-supported, + /// One of the arguments is invalid. + /// + /// POSIX equivalent: EINVAL + invalid-argument, + /// Not enough memory to complete the operation. + /// + /// POSIX equivalent: ENOMEM, ENOBUFS, EAI_MEMORY + out-of-memory, + /// The operation timed out before it could finish completely. + timeout, + /// This operation is incompatible with another asynchronous operation that is already in progress. + /// + /// POSIX equivalent: EALREADY + concurrency-conflict, + /// Trying to finish an asynchronous operation that: + /// - has not been started yet, or: + /// - was already finished by a previous `finish-*` call. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + not-in-progress, + /// The operation has been aborted because it could not be completed immediately. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + would-block, + /// The operation is not valid in the socket's current state. + invalid-state, + /// A new socket resource could not be created because of a system limit. + new-socket-limit, + /// A bind operation failed because the provided address is not an address that the `network` can bind to. + address-not-bindable, + /// A bind operation failed because the provided address is already in use or because there are no ephemeral ports available. + address-in-use, + /// The remote address is not reachable + remote-unreachable, + /// The TCP connection was forcefully rejected + connection-refused, + /// The TCP connection was reset. + connection-reset, + /// A TCP connection was aborted. + connection-aborted, + /// The size of a datagram sent to a UDP socket exceeded the maximum + /// supported size. + datagram-too-large, + /// Name does not exist or has no suitable associated IP addresses. + name-unresolvable, + /// A temporary failure in name resolution occurred. + temporary-resolver-failure, + /// A permanent failure in name resolution occurred. + permanent-resolver-failure, + } + + @since(version = 0.2.0) + enum ip-address-family { + /// Similar to `AF_INET` in POSIX. + ipv4, + /// Similar to `AF_INET6` in POSIX. + ipv6, + } + + @since(version = 0.2.0) + type ipv4-address = tuple; + + @since(version = 0.2.0) + type ipv6-address = tuple; + + @since(version = 0.2.0) + variant ip-address { + ipv4(ipv4-address), + ipv6(ipv6-address), + } + + @since(version = 0.2.0) + record ipv4-socket-address { + /// sin_port + port: u16, + /// sin_addr + address: ipv4-address, + } + + @since(version = 0.2.0) + record ipv6-socket-address { + /// sin6_port + port: u16, + /// sin6_flowinfo + flow-info: u32, + /// sin6_addr + address: ipv6-address, + /// sin6_scope_id + scope-id: u32, + } + + @since(version = 0.2.0) + variant ip-socket-address { + ipv4(ipv4-socket-address), + ipv6(ipv6-socket-address), + } + + /// Attempts to extract a network-related `error-code` from the stream + /// `error` provided. + /// + /// Stream operations which return `stream-error::last-operation-failed` + /// have a payload with more information about the operation that failed. + /// This payload can be passed through to this function to see if there's + /// network-related information about the error to return. + /// + /// Note that this function is fallible because not all stream-related + /// errors are network-related errors. + @unstable(feature = network-error-code) + network-error-code: func(err: borrow) -> option; +} + +/// This interface provides a value-export of the default network handle.. +@since(version = 0.2.0) +interface instance-network { + @since(version = 0.2.0) + use network.{network}; + + /// Get a handle to the default network. + @since(version = 0.2.0) + instance-network: func() -> network; +} + +@since(version = 0.2.0) +interface ip-name-lookup { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-address}; + + @since(version = 0.2.0) + resource resolve-address-stream { + /// Returns the next address from the resolver. + /// + /// This function should be called multiple times. On each call, it will + /// return the next address in connection order preference. If all + /// addresses have been exhausted, this function returns `none`. + /// + /// This function never returns IPv4-mapped IPv6 addresses. + /// + /// # Typical errors + /// - `name-unresolvable`: Name does not exist or has no suitable associated IP addresses. (EAI_NONAME, EAI_NODATA, EAI_ADDRFAMILY) + /// - `temporary-resolver-failure`: A temporary failure in name resolution occurred. (EAI_AGAIN) + /// - `permanent-resolver-failure`: A permanent failure in name resolution occurred. (EAI_FAIL) + /// - `would-block`: A result is not available yet. (EWOULDBLOCK, EAGAIN) + @since(version = 0.2.0) + resolve-next-address: func() -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// Resolve an internet host name to a list of IP addresses. + /// + /// Unicode domain names are automatically converted to ASCII using IDNA encoding. + /// If the input is an IP address string, the address is parsed and returned + /// as-is without making any external requests. + /// + /// See the wasi-socket proposal README.md for a comparison with getaddrinfo. + /// + /// This function never blocks. It either immediately fails or immediately + /// returns successfully with a `resolve-address-stream` that can be used + /// to (asynchronously) fetch the results. + /// + /// # Typical errors + /// - `invalid-argument`: `name` is a syntactically invalid domain name or IP address. + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + resolve-addresses: func(network: borrow, name: string) -> result; +} + +@since(version = 0.2.0) +interface tcp { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.6.{duration}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + @since(version = 0.2.0) + enum shutdown-type { + /// Similar to `SHUT_RD` in POSIX. + receive, + /// Similar to `SHUT_WR` in POSIX. + send, + /// Similar to `SHUT_RDWR` in POSIX. + both, + } + + /// A TCP socket resource. + /// + /// The socket can be in one of the following states: + /// - `unbound` + /// - `bind-in-progress` + /// - `bound` (See note below) + /// - `listen-in-progress` + /// - `listening` + /// - `connect-in-progress` + /// - `connected` + /// - `closed` + /// See + /// for more information. + /// + /// Note: Except where explicitly mentioned, whenever this documentation uses + /// the term "bound" without backticks it actually means: in the `bound` state *or higher*. + /// (i.e. `bound`, `listen-in-progress`, `listening`, `connect-in-progress` or `connected`) + /// + /// In addition to the general error codes documented on the + /// `network::error-code` type, TCP socket methods may always return + /// `error(invalid-state)` when in the `closed` state. + @since(version = 0.2.0) + resource tcp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the TCP/UDP port is zero, the socket will be bound to a random free port. + /// + /// Bind can be attempted multiple times on the same socket, even with + /// different arguments on each iteration. But never concurrently and + /// only as long as the previous bind failed. Once a bind succeeds, the + /// binding can't be changed anymore. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-argument`: `local-address` is not a unicast address. (EINVAL) + /// - `invalid-argument`: `local-address` is an IPv4-mapped IPv6 address. (EINVAL) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// When binding to a non-zero port, this bind operation shouldn't be affected by the TIME_WAIT + /// state of a recently closed socket on the same local address. In practice this means that the SO_REUSEADDR + /// socket option should be set implicitly on all platforms, except on Windows where this is the default behavior + /// and SO_REUSEADDR performs something different entirely. + /// + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Connect to a remote endpoint. + /// + /// On success: + /// - the socket is transitioned into the `connected` state. + /// - a pair of streams is returned that can be used to read & write to the connection + /// + /// After a failed connection attempt, the socket will be in the `closed` + /// state and the only valid action left is to `drop` the socket. A single + /// socket can not be used to connect more than once. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: `remote-address` is not a unicast address. (EINVAL, ENETUNREACH on Linux, EAFNOSUPPORT on MacOS) + /// - `invalid-argument`: `remote-address` is an IPv4-mapped IPv6 address. (EINVAL, EADDRNOTAVAIL on Illumos) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The socket is already attached to a different network. The `network` passed to `connect` must be identical to the one passed to `bind`. + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN) + /// - `invalid-state`: The socket is already in the `listening` state. (EOPNOTSUPP, EINVAL on Windows) + /// - `timeout`: Connection timed out. (ETIMEDOUT) + /// - `connection-refused`: The connection was forcefully rejected. (ECONNREFUSED) + /// - `connection-reset`: The connection was reset. (ECONNRESET) + /// - `connection-aborted`: The connection was aborted. (ECONNABORTED) + /// - `remote-unreachable`: The remote address is not reachable. (EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `not-in-progress`: A connect operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// The POSIX equivalent of `start-connect` is the regular `connect` syscall. + /// Because all WASI sockets are non-blocking this is expected to return + /// EINPROGRESS, which should be translated to `ok()` in WASI. + /// + /// The POSIX equivalent of `finish-connect` is a `poll` for event `POLLOUT` + /// with a timeout of 0 on the socket descriptor. Followed by a check for + /// the `SO_ERROR` socket option, in case the poll signaled readiness. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-connect: func(network: borrow, remote-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-connect: func() -> result, error-code>; + /// Start listening for new connections. + /// + /// Transitions the socket into the `listening` state. + /// + /// Unlike POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. (EDESTADDRREQ) + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN, EINVAL on BSD) + /// - `invalid-state`: The socket is already in the `listening` state. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE) + /// - `not-in-progress`: A listen operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the listen operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `listen` as part of either `start-listen` or `finish-listen`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-listen: func() -> result<_, error-code>; + @since(version = 0.2.0) + finish-listen: func() -> result<_, error-code>; + /// Accept a new client socket. + /// + /// The returned socket is bound and in the `connected` state. The following properties are inherited from the listener socket: + /// - `address-family` + /// - `keep-alive-enabled` + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// - `hop-limit` + /// - `receive-buffer-size` + /// - `send-buffer-size` + /// + /// On success, this function returns the newly accepted client socket along with + /// a pair of streams that can be used to read & write to the connection. + /// + /// # Typical errors + /// - `invalid-state`: Socket is not in the `listening` state. (EINVAL) + /// - `would-block`: No pending connections at the moment. (EWOULDBLOCK, EAGAIN) + /// - `connection-aborted`: An incoming connection was pending, but was terminated by the client before this listener could accept it. (ECONNABORTED) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + accept: func() -> result, error-code>; + /// Get the bound local address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the remote address. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not connected to a remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether the socket is in the `listening` state. + /// + /// Equivalent to the SO_ACCEPTCONN socket option. + @since(version = 0.2.0) + is-listening: func() -> bool; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Hints the desired listen queue size. Implementations are free to ignore this. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// + /// # Typical errors + /// - `not-supported`: (set) The platform does not support changing the backlog size after the initial listen. + /// - `invalid-argument`: (set) The provided value was 0. + /// - `invalid-state`: (set) The socket is in the `connect-in-progress` or `connected` state. + @since(version = 0.2.0) + set-listen-backlog-size: func(value: u64) -> result<_, error-code>; + /// Enables or disables keepalive. + /// + /// The keepalive behavior can be adjusted using: + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// These properties can be configured while `keep-alive-enabled` is false, but only come into effect when `keep-alive-enabled` is true. + /// + /// Equivalent to the SO_KEEPALIVE socket option. + @since(version = 0.2.0) + keep-alive-enabled: func() -> result; + @since(version = 0.2.0) + set-keep-alive-enabled: func(value: bool) -> result<_, error-code>; + /// Amount of time the connection has to be idle before TCP starts sending keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPIDLE socket option. (TCP_KEEPALIVE on MacOS) + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-idle-time: func() -> result; + @since(version = 0.2.0) + set-keep-alive-idle-time: func(value: duration) -> result<_, error-code>; + /// The time between keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPINTVL socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-interval: func() -> result; + @since(version = 0.2.0) + set-keep-alive-interval: func(value: duration) -> result<_, error-code>; + /// The maximum amount of keepalive packets TCP should send before aborting the connection. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPCNT socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-count: func() -> result; + @since(version = 0.2.0) + set-keep-alive-count: func(value: u32) -> result<_, error-code>; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + hop-limit: func() -> result; + @since(version = 0.2.0) + set-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which can be used to poll for, or block on, + /// completion of any of the asynchronous operations of this socket. + /// + /// When `finish-bind`, `finish-listen`, `finish-connect` or `accept` + /// return `error(would-block)`, this pollable can be used to wait for + /// their success or failure, after which the method can be retried. + /// + /// The pollable is not limited to the async operation that happens to be + /// in progress at the time of calling `subscribe` (if any). Theoretically, + /// `subscribe` only has to be called once per socket and can then be + /// (re)used for the remainder of the socket's lifetime. + /// + /// See + /// for more information. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Initiate a graceful shutdown. + /// + /// - `receive`: The socket is not expecting to receive any data from + /// the peer. The `input-stream` associated with this socket will be + /// closed. Any data still in the receive queue at time of calling + /// this method will be discarded. + /// - `send`: The socket has no more data to send to the peer. The `output-stream` + /// associated with this socket will be closed and a FIN packet will be sent. + /// - `both`: Same effect as `receive` & `send` combined. + /// + /// This function is idempotent; shutting down a direction more than once + /// has no effect and returns `ok`. + /// + /// The shutdown function does not close (drop) the socket. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not in the `connected` state. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + shutdown: func(shutdown-type: shutdown-type) -> result<_, error-code>; + } +} + +@since(version = 0.2.0) +interface tcp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use tcp.{tcp-socket}; + + /// Create a new TCP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_STREAM, IPPROTO_TCP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind`/`connect` + /// is called, the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-tcp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +interface udp { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + /// A received datagram. + @since(version = 0.2.0) + record incoming-datagram { + /// The payload. + /// + /// Theoretical max size: ~64 KiB. In practice, typically less than 1500 bytes. + data: list, + /// The source address. + /// + /// This field is guaranteed to match the remote address the stream was initialized with, if any. + /// + /// Equivalent to the `src_addr` out parameter of `recvfrom`. + remote-address: ip-socket-address, + } + + /// A datagram to be sent out. + @since(version = 0.2.0) + record outgoing-datagram { + /// The payload. + data: list, + /// The destination address. + /// + /// The requirements on this field depend on how the stream was initialized: + /// - with a remote address: this field must be None or match the stream's remote address exactly. + /// - without a remote address: this field is required. + /// + /// If this value is None, the send operation is equivalent to `send` in POSIX. Otherwise it is equivalent to `sendto`. + remote-address: option, + } + + /// A UDP socket handle. + @since(version = 0.2.0) + resource udp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the port is zero, the socket will be bound to a random free port. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Set up inbound & outbound communication channels, optionally to a specific peer. + /// + /// This function only changes the local socket configuration and does not generate any network traffic. + /// On success, the `remote-address` of the socket is updated. The `local-address` may be updated as well, + /// based on the best network path to `remote-address`. + /// + /// When a `remote-address` is provided, the returned streams are limited to communicating with that specific peer: + /// - `send` can only be used to send to this destination. + /// - `receive` will only return datagrams sent from the provided `remote-address`. + /// + /// This method may be called multiple times on the same socket to change its association, but + /// only the most recently returned pair of streams will be operational. Implementations may trap if + /// the streams returned by a previous invocation haven't been dropped yet before calling `stream` again. + /// + /// The POSIX equivalent in pseudo-code is: + /// ```text + /// if (was previously connected) { + /// connect(s, AF_UNSPEC) + /// } + /// if (remote_address is Some) { + /// connect(s, remote_address) + /// } + /// ``` + /// + /// Unlike in POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-state`: The socket is not bound. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + %stream: func(remote-address: option) -> result, error-code>; + /// Get the current bound address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the address the socket is currently streaming to. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not streaming to a specific remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + unicast-hop-limit: func() -> result; + @since(version = 0.2.0) + set-unicast-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which will resolve once the socket is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource incoming-datagram-stream { + /// Receive messages on the socket. + /// + /// This function attempts to receive up to `max-results` datagrams on the socket without blocking. + /// The returned list may contain fewer elements than requested, but never more. + /// + /// This function returns successfully with an empty list when either: + /// - `max-results` is 0, or: + /// - `max-results` is greater than 0, but no results are immediately available. + /// This function never returns `error(would-block)`. + /// + /// # Typical errors + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + receive: func(max-results: u64) -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready to receive again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource outgoing-datagram-stream { + /// Check readiness for sending. This function never blocks. + /// + /// Returns the number of datagrams permitted for the next call to `send`, + /// or an error. Calling `send` with more datagrams than this function has + /// permitted will trap. + /// + /// When this function returns ok(0), the `subscribe` pollable will + /// become ready when this function will report at least ok(1), or an + /// error. + /// + /// Never returns `would-block`. + check-send: func() -> result; + /// Send messages on the socket. + /// + /// This function attempts to send all provided `datagrams` on the socket without blocking and + /// returns how many messages were actually sent (or queued for sending). This function never + /// returns `error(would-block)`. If none of the datagrams were able to be sent, `ok(0)` is returned. + /// + /// This function semantically behaves the same as iterating the `datagrams` list and sequentially + /// sending each individual datagram until either the end of the list has been reached or the first error occurred. + /// If at least one datagram has been sent successfully, this function never returns an error. + /// + /// If the input list is empty, the function returns `ok(0)`. + /// + /// Each call to `send` must be permitted by a preceding `check-send`. Implementations must trap if + /// either `check-send` was not called or `datagrams` contains more items than `check-send` permitted. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The socket is in "connected" mode and `remote-address` is `some` value that does not match the address passed to `stream`. (EISCONN) + /// - `invalid-argument`: The socket is not "connected" and no value for `remote-address` was provided. (EDESTADDRREQ) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// - `datagram-too-large`: The datagram is too large. (EMSGSIZE) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + send: func(datagrams: list) -> result; + /// Create a `pollable` which will resolve once the stream is ready to send again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } +} + +@since(version = 0.2.0) +interface udp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use udp.{udp-socket}; + + /// Create a new UDP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_DGRAM, IPPROTO_UDP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind` is called, + /// the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-udp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import network; + @since(version = 0.2.0) + import instance-network; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import udp; + @since(version = 0.2.0) + import udp-create-socket; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import tcp; + @since(version = 0.2.0) + import tcp-create-socket; + @since(version = 0.2.0) + import ip-name-lookup; +} diff --git a/crates/cpex-wasm-host/wit/world.wit b/crates/cpex-wasm-host/wit/world.wit new file mode 100644 index 00000000..0990f8b1 --- /dev/null +++ b/crates/cpex-wasm-host/wit/world.wit @@ -0,0 +1,231 @@ +// Location: ./crates/cpex-wasm-host/wit/world.wit +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WIT world definition for the CPEX plugin component. +// Defines the types and exported function that the WASM host uses to invoke plugins. + +package cpex:plugin; + +interface types { + enum role { + system, + developer, + user, + assistant, + tool, + } + + enum channel { + analysis, + commentary, + final, + } + + enum resource-type { + file, + blob, + uri, + database, + api, + memory, + artifact, + } + + enum subject-type { + user, + agent, + service, + system, + } + + record image-source { + source-type: string, + data: string, + media-type: option, + } + + record video-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + record audio-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + record document-source { + source-type: string, + data: string, + media-type: option, + title: option, + } + + record tool-call { + tool-call-id: string, + name: string, + arguments: string, + namespace: option, + } + + record tool-result { + tool-call-id: string, + tool-name: string, + content: string, + is-error: bool, + } + + record cmf-resource { + resource-request-id: string, + uri: string, + name: option, + description: option, + resource-type: resource-type, + content: option, + blob: option>, + mime-type: option, + size-bytes: option, + annotations: string, + version: option, + } + + record resource-reference { + resource-request-id: string, + uri: string, + name: option, + resource-type: resource-type, + range-start: option, + range-end: option, + selector: option, + } + + record prompt-request { + prompt-request-id: string, + name: string, + arguments: string, + server-id: option, + } + + record prompt-result { + prompt-request-id: string, + prompt-name: string, + messages: string, + content: option, + is-error: bool, + error-message: option, + } + + variant content-part { + text(string), + thinking(string), + tool-call(tool-call), + tool-result(tool-result), + cmf-resource(cmf-resource), + resource-ref(resource-reference), + prompt-request(prompt-request), + prompt-result(prompt-result), + image(image-source), + video(video-source), + audio(audio-source), + document(document-source), + } + + record message { + schema-version: string, + role: role, + content: list, + channel: option, + } + + record message-payload { + message: message, + } + + record request-extension { + environment: option, + request-id: option, + timestamp: option, + trace-id: option, + span-id: option, + } + + record subject-extension { + id: option, + subject-type: option, + roles: list, + permissions: list, + teams: list, + claims: list>, + } + + record security-extension { + labels: list, + classification: option, + subject: option, + auth-method: option, + } + + record http-extension { + request-headers: list>, + response-headers: list>, + } + + record meta-extension { + entity-type: option, + entity-name: option, + tags: list, + scope: option, + properties: list>, + } + + record extensions { + request: option, + security: option, + http: option, + meta: option, + } + + record plugin-context { + local-state: string, + global-state: string, + } + + record plugin-violation { + code: string, + reason: string, + description: option, + details: string, + proto-error-code: option, + } + + record plugin-result { + continue-processing: bool, + modified-payload: option, + modified-extensions: option, + violation: option, + metadata: option, + } +} + +world plugin { + import wasi:io/poll@0.2.6; + import wasi:io/error@0.2.6; + import wasi:io/streams@0.2.6; + import wasi:clocks/monotonic-clock@0.2.6; + import wasi:http/types@0.2.6; + import wasi:http/outgoing-handler@0.2.6; + + use types.{message-payload, extensions, plugin-context, plugin-result}; + + export handle-hook: func( + payload: message-payload, + extensions: extensions, + ctx: plugin-context + ) -> plugin-result; +} diff --git a/crates/cpex-wasm-plugin/Cargo.lock b/crates/cpex-wasm-plugin/Cargo.lock new file mode 100644 index 00000000..27f96516 --- /dev/null +++ b/crates/cpex-wasm-plugin/Cargo.lock @@ -0,0 +1,787 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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-payload" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tokio", + "tracing", + "uuid", + "wildmatch", +] + +[[package]] +name = "cpex-wasm-plugin" +version = "0.1.0" +dependencies = [ + "cpex-payload", + "serde", + "serde_json", + "wit-bindgen 0.57.1", + "wit-bindgen-rt", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[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.1", + "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.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +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 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser 0.247.0", +] + +[[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 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.247.0", + "wasmparser 0.247.0", +] + +[[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 = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", +] + +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + +[[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 0.51.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro 0.57.1", +] + +[[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 0.244.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.247.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "653c85dd7aee6fe6f4bded0d242406deadae9819029ce6f7d258c920c384358a" + +[[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 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata 0.247.0", + "wit-bindgen-core 0.57.1", + "wit-component 0.247.0", +] + +[[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 0.51.0", + "wit-bindgen-rust 0.51.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core 0.57.1", + "wit-bindgen-rust 0.57.1", +] + +[[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 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.247.0", + "wasm-metadata 0.247.0", + "wasmparser 0.247.0", + "wit-parser 0.247.0", +] + +[[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 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.247.0", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/cpex-wasm-plugin/Cargo.toml b/crates/cpex-wasm-plugin/Cargo.toml new file mode 100644 index 00000000..0ebf8f68 --- /dev/null +++ b/crates/cpex-wasm-plugin/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cpex-wasm-plugin" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "0.57" +wit-bindgen-rt = "0.44" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = "1" +cpex-payload = { path = "../cpex-payload" } + +[package.metadata.component] +package = "cpex:plugin" diff --git a/crates/cpex-wasm-plugin/Makefile b/crates/cpex-wasm-plugin/Makefile new file mode 100644 index 00000000..50d6e0a7 --- /dev/null +++ b/crates/cpex-wasm-plugin/Makefile @@ -0,0 +1,21 @@ +.PHONY: build validate inspect clean + +TARGET = wasm32-wasip2 +WORKSPACE_TARGET_DIR = target +WASM_HOST_DIR = ../cpex-wasm-host/wasm + +build: + cargo build --target $(TARGET) --release + cp $(WORKSPACE_TARGET_DIR)/$(TARGET)/release/cpex_wasm_plugin.wasm plugin.wasm + cp plugin.wasm $(WASM_HOST_DIR)/ + +validate: plugin.wasm + wasm-tools validate plugin.wasm + @echo "plugin.wasm is valid" + +inspect: plugin.wasm + wasm-tools component wit plugin.wasm + +clean: + cargo clean + rm -f plugin.wasm diff --git a/crates/cpex-wasm-plugin/README.md b/crates/cpex-wasm-plugin/README.md new file mode 100644 index 00000000..aae0005e --- /dev/null +++ b/crates/cpex-wasm-plugin/README.md @@ -0,0 +1,215 @@ +# cpex-wasm-plugin + +A WebAssembly (WASM) component that compiles CPEX plugins into a portable `plugin.wasm` binary. The host (`cpex-wasm-host`) loads and executes this component at runtime, enabling sandboxed plugin execution across platforms. + +## How It Works + +1. **WIT Interface (`wit/world.wit`)** defines the contract between host and plugin. The plugin exports a single function: + + ```wit + export handle-hook: func( + payload: message-payload, + extensions: extensions, + ctx: plugin-context + ) -> plugin-result; + ``` + + The host calls `handle-hook` with a CMF message payload, extensions (security, HTTP, meta, request), and plugin context. The plugin returns a result indicating whether to allow/deny processing, optionally with modified payload or extensions. + +2. **Plugin Logic (`src/lib.rs`)** implements the `Guest` trait generated by `wit-bindgen`. It converts WIT types to native Rust types, delegates to the plugin logic in `cpex-payload`, and converts the result back to WIT types. + +3. **Type Conversions (`src/conversions.rs`)** handles bidirectional mapping between WIT-generated types and native `cpex-payload` types (MessagePayload, Extensions, PluginContext, PluginResult). + +## Why `conversions.rs` Exists + +The WASM boundary cannot pass rich Rust types directly. WIT only supports simple primitives, lists, tuples, strings, and records. The native `cpex-payload` types use `HashMap`, `HashSet`, `Arc`, `serde_json::Value`, etc. — none of which can cross the WASM boundary as-is. + +`conversions.rs` bridges this gap so plugin authors can write normal Rust using native types: + +| Native Rust type | WIT representation | +|---|---| +| `HashMap` | `string` (JSON-serialized) | +| `HashSet` | `list` | +| `HashMap` | `list>` | +| `MonotonicSet` | `list` | +| `Arc` | `security-extension` (flat record) | +| `serde_json::Value` | `string` (JSON-serialized) | +| `Vec` | `string` (JSON-serialized) | + +Without this layer, you'd have to rewrite all plugin logic to work directly with WIT's flat types, losing access to the entire `cpex-payload` library. The conversion layer handles the impedance mismatch at the WASM boundary transparently. + +## Project Structure + +``` +cpex-wasm-plugin/ +├── Cargo.toml # Crate config (cdylib target, wit-bindgen deps) +├── Makefile # Build commands +├── src/ +│ ├── lib.rs # Plugin entry point (implements Guest trait) +│ └── conversions.rs # WIT <-> native type mappings +└── wit/ + ├── world.wit # Plugin interface definition + └── deps/ # WASI interface dependencies (io, http, clocks, etc.) +``` + +## Prerequisites + +- Rust toolchain with the `wasm32-wasip2` target: + ```sh + rustup target add wasm32-wasip2 + ``` +- `wasm-tools` CLI (for validation and inspection): + ```sh + cargo install wasm-tools + ``` + +## Building + +From this directory: + +```sh +make build +``` + +This will: +1. Compile the crate to `wasm32-wasip2` in release mode +2. Copy the output to `plugin.wasm` +3. Copy `plugin.wasm` to `../cpex-wasm-host/wasm/` for the host to load + +## Validating + +After building, verify that `plugin.wasm` is a well-formed WASM component: + +```sh +make validate +``` + +This runs `wasm-tools validate` on the binary. Silent output (no errors) means the component is valid. + +## Inspecting + +To see the full WIT interface embedded in the compiled component: + +```sh +make inspect +``` + +This prints all exported functions, imported interfaces, and type definitions. You should see: + +``` +export handle-hook: func(payload: message-payload, extensions: extensions, ctx: plugin-context) -> plugin-result; +``` + +This confirms the host will be able to call your plugin correctly. + +## Cleaning + +To clean build artifacts: + +```sh +make clean +``` + +## Writing a New Plugin + +You can write any plugin in `cpex-payload/src/plugins/` and invoke it from here. All plugin functions must follow the same signature: + +```rust +pub fn my_plugin( + payload: &MessagePayload, + extensions: &Extensions, + ctx: &PluginContext, +) -> PluginResult +``` + +### Step 1: Create your plugin in `cpex-payload` + +Add a new file at `crates/cpex-payload/src/plugins/my_plugin.rs`: + +```rust +use crate::cmf::message::MessagePayload; +use crate::context::PluginContext; +use crate::error::PluginViolation; +use crate::extensions::Extensions; +use crate::hooks::PluginResult; + +pub fn my_plugin( + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &PluginContext, +) -> PluginResult { + // Your logic here — inspect payload/extensions, return allow or deny + PluginResult::allow() +} +``` + +Then export it in `crates/cpex-payload/src/plugins/mod.rs`: + +```rust +pub mod my_plugin; +``` + +### Step 2: Point `lib.rs` to your plugin + +In `crates/cpex-wasm-plugin/src/lib.rs`, change the function call: + +```rust +let result = cpex_payload::plugins::my_plugin::my_plugin( + &native_payload, + &native_extensions, + &native_ctx, +); +``` + +### Step 3: Build + +```sh +make build +``` + +This produces `plugin.wasm` ready for the host to load. + +### Example: Identity Checker + +The default plugin is the identity checker (`cpex-payload/src/plugins/identity_checker.rs`). It demonstrates: + +- Reading security extensions (labels, subject identity, roles) +- Distinguishing pre-invoke vs post-invoke by checking if the message contains tool calls or tool results +- Denying requests when the caller lacks the required role for PII data +- Returning `PluginResult::allow()` or `PluginResult::deny(violation)` + +```rust +pub fn identity_check( + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &PluginContext, +) -> PluginResult { + let is_result = payload.message.is_tool_result(); + + if !is_result { + // PRE-INVOKE: check caller identity and roles + if let Some(ref security) = extensions.security { + if let Some(ref subject) = security.subject { + if security.has_label("PII") && !subject.roles.contains("hr_admin") { + return PluginResult::deny(PluginViolation::new( + "insufficient_role", + "Tool requires 'hr_admin' role for PII data", + )); + } + } + } + } + + PluginResult::allow() +} +``` + +This is wired up in `lib.rs` as: + +```rust +let result = cpex_payload::plugins::identity_checker::identity_check( + &native_payload, + &native_extensions, + &native_ctx, +); +``` diff --git a/crates/cpex-wasm-plugin/plugin.wasm b/crates/cpex-wasm-plugin/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..55472f0bc32b560e3fbc1b61ccc94b25d0a5c14c GIT binary patch literal 604424 zcmeFa3z%G2b?18?^{(nxNoqZ8$@Zy2BvN7}n#5x{zT6v?K5U#jd|!O?@y*A*1K&tk z0;?@sLfv-cdp)=fHZiYY5(bND}dxDr*5o;r2nR2sLo$I<4H&))fracygoMzPNq6+qO_ zCh@_om8f&Y9-ED;PF%Nm^t#1`PBc@Ac8ssW z6`n0xBkQj5S#1Q4UH5{$&p&?s!qxGH_*kPT)~!Srj4!ru{qq*CI(6)MN8^pHbK_`B z7!-$#1wSm6`fqHA4?ZKlwpv@babfZ3wap_(Z#a5f_^e#NaP(C3@WR5eg~h|yEmjs! zoH$;IDyL3BI8hW|vk;%Ebo6rXRfmrs-+S!HLgl){*B)JH9zOMg>#r387Mk|rZ+rA7jHZX{B;f6HO=^% zpN&toH|HNu9ev^TN1-P=qe@+mlJ=3KLgh&jSTbBwBLJHiB(3JP$F6m4B&~&G|L@Vg z&%1H)=t8@F_`2&(EEhN_hILfr4 zukEJ$arC+)?FMuD=v9j+PSMJy&;m_8|LCcs*Ifk$W*LHwCr_QY_T(b3^KLI6L#P0N zK?ya@4i`wHZhWdXrk1wPXXDv`C>9RCfIm61ijkeMNx9c&p^2Hi2{+W6;!|n6GSNbk!~7>*Od%Ltc}B#w zVN!}`qE30x;|-%j7jGQFbi6sL8W!U%QPr>!Z;PtdtN5I#5nzmWh@tu!y7wehmqt6| zYtzQz>kc2k5nj={>cq90{fAH8SbhF6URI*|RaZm*M~|n``SHo~;%hDdIkO9J(Q6Oy z^>bmt_(~iIBnw4)l=ifdM*a9y9DY3oy!j76apUn5hmWMuuJ}~7ee}8;j-5JjonS~> z1!X7AMW!b6e{q$^eR}{;*EPtb^Vk0RI(xT_v)jEkHC@kcA=P)fOB+jc*BIhdU5e& zx#W*K6hpf=y2%H=?T4m%QdvCw0=lzs6$?nxVkXne#bZa+rCA_4T(0S##@GB{e9e!= z*IX1|b1@yOdwBN4x*WupRMh%UR8;cPO4Q0bu#iUo8$DS?I-e~+&@4e*t#PQcDMu;|mL2AYVM;)*dQ2a`Y!F zQP`4dZEaAin0Q@ikA2uX$fY#hcaAvFolsx|h;d!R?r{H+Qc+afC5CdL$6( z??;>SS4Dr`A2suO{~&^4E*=(_Gj`~@!PwZ!cUfy|9BsVn<{Lm(8~!2*C2yhSTR@7ViCh>`4`_n7zK!;(ZFHt;gXL^Y z8@%;x%+$A|*yW+ySev8a)+p5V1HR== z5Nckn?~xn6yy?xOO_MFItkCPBG2J|&@ws(0t}$Mv@nW#I)GyF%I+6VB&kBc3qnTsy zSGXIp%2CvmxV0;eF0g?^bUSk5+8-VI?niE`^&uTy?^;{pF>Bzq+6J(lmY8L$8xQl& zbBq$w5f0g!i8PEJ27iw*e5gvy5(s}VO-MuS9(TwS5{MzLFL-Ebgq2%V9 z>HqFibf~6$p}>oxLsfpfh=O|RwR=)Rb-&2f_o72} z`>OBBi>%3;^ta*P^15>KP1dllo{ymj$#%%clb;((Kb7Rw=8;$a^|mpu{imk;^r9D8 zA9%bdO8&S$H({_Z8YYlvoHD&{?qB4x-4T}9=R$BNPDNw%^L^V!at5_N8m|xT+cA>A z%(Pc3vvZ`3o55a@k5=bt`_3CFobv%|Ihw%K_MN{{aR)@S%38N?ba4#09$MJ;*}69t zw=396RzlNcTSAnKRni$0C(J!}2(v$8CWI&*UuG&>!b%NJvM5BgaclIxVj+*}4yutA zc3){B9?24TW}bx?QGLvMSS&32#;iwU38u6j>>Ia`tW+q6X;~ow7p3p2zq8&*kzkS} zZ6>WKX(g?u{g))9`9EKHXCL&3pZbqPOP`ZwGm2WxB#E0z9EY#^z<*8Nul8T;{-YEL z8uh=|{D51-ulY}Z8ulyv8LBI)CCysIU;D4!snsf$rX%)@XUJZu>c3h0TmF-{&b8ts zO$954JYWv>gMjJ&3xJJ0S1xQBt$I?vJ-v&0M?VS>R0Sg%Ga#gD8!Rt5DCMDJ%%W(9 zQKFY|Rk_L9tU8R%%*-%eysLDZ8}z04kL;LP`JWXxOpk}RL7T!MYjs@PSgTYhS?Ta! zd#2KiE6rw_L^V+Dy0K|9qYfnc&;HtRtf)-N?MK5Xr@7}vL`nhaShstYW7bp z1AVPtuh(j|MztPQt9-8sLYjnbD*7c%ClyGp$?RzYK|`~k(yYw2TFsf6jq#Q=-I8vh zyH!Z0R%=#fRWxo=nj}zdjb|&f+qR!`?v8C0;Gc zSHb)je=PY}QcY2&YOhar-y9E?md-@&o1=mKx#I)xx97+0Z>;y9vgGaeyIl8u#XrC1 zo@f2@5zG6S{k`{#?%Tb8>z@C^-u{FAecb*oC93(w_V*os=$gF8H~mt3d$0esjx{|l$u0at~&g@WB>BxiG{^~i8bfQajXO(N?)bg&e~f=U{*Cya_)p_M zj(w#Q!QUGd%VzmMM(zd3$m{44Qa#($7L z=dSvHNk0*vjXxQGp#II|uab`^udIAF`E>F?^7-U*$=8xkB#$KDNIo5ZF8yfw`S@G$ zL-Cj5&&H3%e-l3%eAz23lfI;KTl&8A7t?>2{&n)k^jpbClRr=HOy8dVoAl?>UrS$> z-krW8eM@>rdT;t0>8sMWrLRnXJ$-rl*7TnA)#-1hFHO&+zncDs^p^Cl^!4eR(wC%P zOh23cO8U#`Yt!FP|15bd`9}JQ^h4>V(zEIN(}$ABlTRi8ko-;Z@8XA(Z>CG>->3hk z`p(K-m3LR(Q~ABh%c>7nK3(~2<%^Zut7j{(ufDGO_1f>$-%$O}^?zM`U;P)Vuc_Wy z{q5>Is{g6_;p%^`exUm9`j@MJSpCcDD{Bu||F(Kh^{=asSC?v^sNPb0aqVpNCAC{? zpQ_$gdui>#>d)0~uf3)E&FbG*Utaq{_4C!wRUfMUdiAr_f2jVC>dR_hsy!s()AgM)m8}k5<1@{do0T)xW7eP<>79UA1@Dey8@kwfEG1ulDD) z2Wy|MeYW zXwbc;U)g2JEFDA_Cygx4(%s42AfCTCnae7J$~+%B{OP{*HVR~w-ASXDCI^xON>=*S zLGp~5D2oQKqn6VbCnU4<;-u1v+se@C)s&aq)j>=~61B`qq*c= zWVJzb1#6PB*JhYxy(-moUwf+{jqQ8feT{zgtC!7?{W4mv0&(qItPgEku3emTcvRI9 z`&9w5S9-`gY1>sj1^}S7{3<5!DfX(O7{C5?_?5IXTJGL2z}05{h5in@^A4Hq`&nmD z=i-3p?3#Y!J_&^3e_6aciTWuF8w@TCwrC`-*fF?yes%-w-`2lK28(Kq_TnrVTrmHX zTgV#?&ffn1rD(qUdj067e2FiOBEYi&s9SH#O=a=ksM1eo>&PwCR;M~E(om7N?@zlt zlR?AR4n+6AQS}4Q8B27(B-{%-t|iuTQqiT6fZ`E#+P=p8MN2P|QjVrxq@@=Py{MF4 z)b*lTdQsDhTIoe~OwabDjeg}|?Vm#tmAmsEGOT$IE9>Z>^{3oH>knAYJ81nWchLG% z?x6Lj+(GM4xr5cw4ptavCYOd(iAKMwN2mMWUkv`U^sV_Ag_(6Q&LnuD`jknHmR;u;cITB}qw z?KM0}azKej9}Wiey)-=lpM1|t3?LfJli55&88M^#(M$wQ#h1-g)$>7hanSIC2ob2# zzKyPGEe3c)@ri22e=7PJYRpTm^r;a#(CGuvxDEhyEU4Lj1L^=CIf1Q`gTA5NzWqDG z?#{eyYIYY`UDa6KL`L_n35?FBeVGp!O_uq9P5Uw*uxVfB12*l;d|;T3F%OJ|RXhuG zkWXS@lK?k3%YYl4Wxx&2GT;Vh8E|7yWx!WrPya2jmQ~r4ry^;eYh!rkV+17N7*D6X(Ev6Xt+XPMMU9a>}G+lv5_9g2DrJC!V?m(IncQ z1dz2~2FThk17wt22FOt^0<2pbtK}sCX0AoHUmN^?^QMnSX`Hs5q}8iuDa%w=AN<*S z&L9$z{+XEsnH6Ey1fUL0rHD>H8b!UjF9Qcxna1GbR;J(J*KJvy7nX0~?FS!!JblTn z2dRoBOR`&xh$_}P&k|d%X17)IIbRLG7OWb}w{YsR8DUaluonv-dV{EAkal0LRS89@knuxs6%4`Hy*?S zpB^`L!%YnYuZWvan_=3as8pL!R3CU0)eS|eZ4^b-l~6?O3_zp-7#3JplB51`Dn@0w zz9$*g1GTssnAF**Py-=`OOeZv@uf>uq!jMVDhDMPEyerM2_@k5Odzd5{*}QQG=vH& zV)qw>kw7$AWp8vq!ua66Xuek;bb5`{BrbgzIQh|)t)dZ1Q|!lGDOsjsJ{{4GcL&fx zOs~9rrlN`=Bh$k!SH)RnPkP1vbhiU_7Z22)j7)L&4nfjSt@(ZPym%aa$tvFGDtHFk zBlTa@W4Ln-u8|=}N`$QpvUw*ob)-ZwVIPlQIOwQVfbG7_WXpKCU`#>FZE)rwNUk!# zo^A@UAuH~_)@Vl<5+WoU2Q5-C0NB%AZHB8}LsPe|?q0G#ZMf9F{i!PEUA7;o(^9+l zr&jc2!|Ea;oys0iEtUe24VSu7YLdNs_WpFcfHzyf zPT(I#1)NR%Lro`h`=e(F@;%W33GloFh>N42cJHo0hIgr-M&uVe5E@^)5*i)Ie`sQ~ zdLikwsv5U1lbA=T&!`TZBdwAjDjGp8G##lM0#A}QAX9BG28FSg)}`s>Nd)&81Rw%K zOs-M`$CVobN9zCCC}=6SVP-}Q@K%<6WkrScs0+h|U^-N7(M>xulMH6o|sVL)&qYU0{ zhw)X6GHRX}r0K^prZ6wWA2A8-^o{2537pJfoH}T}gz-p&P9>pf(=yajFQFfa$8sXo zQp!-SXb8Z5!m=h%9aurBaf&3bcREW?D%vx1&1>NuvOj69*%yZ4&n~E&8RlQbH_b2{ z&A{;PO;)kT1Qxc^(>NfUNgT1T*UWB>kZELAX*65y&dltF?#4}<=e7Wl%r4MTB1z#A z>3x?*Kf$j{=K1rRNigQz^YbRtJrbtH!CB2t<|oFi&NgcKC)DCiFRBH521r~}EnBJO z$Kxkj%NA<+r>MVA43;@+LF0LXwQQ!AeI74NtaajL6Se%9$IJR_*+?xHdAzK@mTn0z z>#qgnKmH+L!OS3QoA6To-Je)9!OT+44>L#-bA2_nwbwF3EwY!`6u3CKFJ`hY#XmLE z;`x&=94y^@3VARd)NbfCU=@7p{xqC9$?BIzAEpfJ@0s}2rO|!yAnC<2I>v*}4ZSAs zeQ8$=Y5Jdqav!mBzI=0V)|XFRd8_(QeHP2w^i}y1@nF*pgOjdJ;Fev}dH|T1UwSf_ zlIUCvxPW(Y@-*;+MGrKuWj<#0%Rmh)dMoZLe>fopFv8Ej+G?c ze~^UL!77k9gA(9o&6=Arg%BBFjlD)na^O|^VgSa)#0sMQ=_Rl`VX*sYfypp;bG~o7 z9R#t~#-sw;*sa{yD@-^nH|EsDPQS^5*n>(%yhZ_ye#2Wqn-U%xTDsz_A=_a+EV!av z-as5Xl@juinjyK16U?21`(m;EE`Qv=?!U9XVm`e;W*Q`W()(g8*~$OK@<&y>lUos5 zo~GoU5>nSoFi$?vJPuaj}CT32u7sQ;LcdvCRuB6 za(+;`q1Q?dWX(Y=DYwm(0US+G??c-KC1FZYYMxs@;RHv(f*)y+HAs~3{wo+;6C<==ty%ZB@WG!IK ztP?!0QNLF0iaL@~tlSaP0i_A-1|a0KeLVd3kJ+nbrDb$?F@8KUJwzf6%*WNmeq+8r zb8&KEa-jGpdvBzlS*JuP63#nFaJ?9Pym>I`*`8T4xHDlmPW5IT%9PwdvTHBl7K20c zz3w3HwJ6n4spJ4-)q))g4qDf6?MawT2JB$F`>~zRu+4b^u<_#LwW7WlWm~^DU7XzE zepl>w>*C~<`VCTVPl|37DMo@pa>7qN->kxMBF1+@{~-v~U=J-*oT6v+>Js`P?K={z z9sFW+A$Ab5aZnc?Htmk0!KeTF-EXQdWSa)*yncT5L;87YpyE_DlTe3Bk%2O4)+&{0 zjVZ%0jx@4YjYQTSI}w8~hGa5$BqYPNzh5n+S4?;WC$~)a!66%`CEvScfH)EirX86 zm4ulhaT0wV{!(KYj4D}%jMH?*e+p91Ia)K@khLLjl_VwNLM1TzV0s%%^Xjtch*r72 z55wp!FYj17!(1NJ3pKh7nbFDaWMxQZb4W;)A-NNZ^~{wSMCV4 zHkdAt?PA+ec8>59W#@`N(_^j0QHIXAoyU3dY=|<3xXWYZg>gm;2%TyfI3}C&>hgXY zge+enSLv!58l>!(x(N4<6V}lgzshO76g4uTnk-_TmgB*lL+hoG-w23-esjb?BC_KS z&vFx`+;R%58YOnWx+a6)db!QW3#sm1uC;r9j&)xf1|#!hsv>B&Nb0d8SGW!2VzRL- zZ2h!}>ZIl%*FpfRDZs!oxHp;KrDZ2-zv~Cnxtr4$)q*;IM|@4c=Ds`4G>o7v6BREaH7k;?u{!f--+cEs)#~`Z3@j*(7-mkO6LY? zZwte`6$o(XN#K^b#MT}xIf<=3EU}?Z=7^+^jgH9(`#j(oi1bFQiqHX*$syk5AZ`ys z{9g;iY;%xq9NfaY)3Wd8Ex{QYCOdfYT?slg@4O7CVXc$haW>l!Sf9AhMvn^mZMfY& zsDOsFx7qJ#HW&oc7eJjf!_47%r+aK?K#?eS^Y9X7LmyZUkQ@3#;WnB@G$Cad+u8lpi;JF8*-i}a0@a^t-O6hHLaQEtDkuBFKOTy(QQjf79lRS@P z!%`cer$h5S+`xJrI8{q^^E^M)9V+wBZ2}A_w1cSHl?aaFqy>#R(R4B<)1v95PmwTb zFO~{R%^q{55LX217fINnp}& z)CLcsQw+`dlE`#%a+73{jwD!3aA^oXwWHUTP*B^Gp6u^9SjqVRvv%Cg%NmS1{L5ws zH_gvBqE!E35){v#k*xaFFTd}tHO31>w&k;9FCYB<55Iz!37Bq6@tD~YMF+p|q1XKJ z>mGjn?|dV=Et>&>KhC>HKNR0~>y;QqnSyPI4Vv@0AN-p?dc_BR<72=5h~-YTIeGAt z5B=&lKl_Ehc;{Cl3bDfgm10`}lb1#}W}VAsz%YFal4}7z%`F4IvkZ8qt-Txq9>jD; zf$xk39ywmXTkbOO9i;f>z@sYwJP30L4iK6@ew>6Y=By)m`wckFZH(~LF8>z`GKt`y zj1awSRQ2l{NcUBJNCuCFWNI>h)ka98ua8m)DoUnQK+;AEPQaUJ@CDwaO2$^wlF8t~ z@HQQMJ|rtv+A>rJp9pVFz;`v&4I<0XFo-PKboJVxLo?Q>sz=#VqhAZjWboyXbZ}aR zia~G5s)1t3nyEgPtea+I$%a8?$)@$olC8m4RIdh1GL-srUS>d{$8`Q6D)aLAm`YB>d=O)P9fBx;k>$$X`$6{eV;YUIXQ9Qldlw0awm zREe#Y){;%xMz6h)@;ZYt6@d=WPPA-lqIxzdnRgU;w|Wfz;>Bl{#4f%5GOjO}lj-L= z;ei)BsPa|+rk)_9#?yTdo|yh9cm+=3mW>X^Xi6;E`pn|gP!)B@D_7~5KsOrLEgHEmO~o2f0BBE0xn7okPj1y1M30wA3qDz3FWR5`gohsc~)D+OZ9ot-T=w z4R)gu^MUZ9x@c;5a%G>D^fab}8Lb{n6OY{?^MS2!WcE%lZc2sqj{&`GrctaSTA68K zn;I|UoOfm~+|E#jabWH;N0(0L7NbVCWo> z%^*_SnG_`(kFnd5N{cY;WsQmkHv9w7j@E<@B9_f7ECoQK$(Om(<|DMZw?*omR54{k zZ!*iloKj{v=#bLt5$e@5%N&*zR+5>1Bz}$1Wc(UBfm(pdv1{Z@#vOXj=RN6Zac%8ykw!wPQPhk^t<#KVa*B3`=4vNvUt=3U37TovzP#T7$@N~7nb-Eytu3pJ zSQ*m|W!t9Pj3}jrno)STBX`fFZbq@D)G{oO`|!is;v_YS5AAA=urON3kO07iAwjar zCcW#jTM$S(m=^L$lxEv_=Rez8Y69Kz>Nj4NZ9PS8rR=PmJ)7>!n;8cTdb;SJhhc1# z{^@kVk+QJ}Y7B1vKdGTHFh3ksmdB3N26x2s-g5U#^(1~#G7@|-M6eNL){pNVya*XP z<9L{VzGJpsa;!1;tw=U79E2jpBy=*AXSW3-yz<7JhwK}M`H^o~KXEfcAciWme-f%t z1s4{XFg$9Lo|oDN^qE{}g@Mo-0ipDXff|Btg9q=T9CU4F7ws|;Lqx^_=bT3Qr)O!A zCF%Z0^NQpB?gB*i_>FS|cTu0<(9C`pEHu)O?(eyjLlMP2`buS1h0m2cP&8Yh@99Oe zhN)*3nK0hVhz7t{7MZ?Xh^VMBjNoGt@muBYubp~SYl(K)Tv7xgFH5u&`n5erE*n4j z=s5)L%D-uMxfp!ttH4^U+GEvzQIY9MtGyVwQtj>bul1_ISMJ! zs~Pc@TW>WZ?sDs`X2f4^z16@X%MNp@Mhz(;PA-@Ue`3L#xNA-`ske3XXGY?YlW7*zS^d zW)mu%=Nxj6LuB_ef)r{gWb)uTx8~-sj8MPaot&fIxaMAY%V=|G5tMnt&U37*-g!=L zbSCs8xyiGGy(8Y(zwWPCZ*dp6J#o%+OG)lLhkHAjUx_=_pG4;AX1YUZ&%f@R;Y23i zA#b>)ME*hoOFfzB4F_c7+~LlCciiD50JC{1q1A6*B3?WCz*K@9soj9xdg$elJKRM> zBK`z-IJsL~Quc`ZlVsl9;lLSth~^IWkaLn_rV5kVyxd4~yQRF79GX>cvXh*3A?!wG z>W_Dl!(#NHXe>C%p^Kh%zjG(K2lN}tygw;-FQkKkLaO(goaD}qI>~KzPI7a**>Lhs za_qjZ?j$$2Rwud8$X+$VmJiyuHkh1~9LeCeMl$tIawLP>8p+_cMlyF>YnhWA_QfFS zqQ){T^~}IS%`RFa8CoM5S|b@+BN@ffxXV$H`cc+eK^NEkRv_0J(2joweA&jQS$VBt{eRz3o)f z)HtCN|20y^jUl|%lzKV;Ev56`g!tuuit}IfMV(-yI5Um&ALEEjGRgh#z4-tvt>*rR z{B8oyljr^?Cnx9rcZ3wC?L*k*??F(kDWlan;Nv))sr@t`vFq3oW!B0v}I@> zdiCC%wupQgbJSd7^!#XU@y~jwlQKKWe;)ro-?o&{bc0JE#HZv1jsl&4-I7lrj^NHG zaG=(lUtp%1aGL6cngaxRi>PT!r+dg2-cPVg_b2!XZU9HQpWxN@!w0qqd+htX8HCCY z=`KA{J>H-v#Bpa_pfaQ&LIXj<_zHf4cD!0`aHq|>f)hMi77^LL8o=Z3@h!bAHg`b9 z_1df8Em)_G`cLh+G#C1P(0b)`d4_$#R-0LwQ9XmPt9R2 za-MRL`I3QCiR21`v1DSNV3tfxEwE(8T+l38EsE7lg|N5vq6X(-W^bEC-d2&JJ@`^+ zty9!HQ)HMeVAxpiR)R^ckxE?fmWhNl$1WbUOoROZL$I$}wuTUjz4d=3CD>2xbgI{!pV z)|~sFx>zrK2^)qL%ispmk}a3Pyftyi$lH${AqkO|bnRQxwQot+z9n7zmaJKyEa_Yo zE!nW4MOvk&Y*kxJ!Zb4{kgO$>!NcLL^X#;@6|ditw`w5UTjvyM$+{6oG^kNMuCsM> zoz3SkF0^maHn%uM$r))cEI^XKEve%5FfXZ{cr)7?+C*+G#}EX^)nZaEPszveE%9u_ z?|(*WR!&Q5Jzq(3M@J&#au2Q%M@O-Rum#_-jU>MqNS0g%u~$ORE}dDz({b~7m$D9< z66*)X$u@wLdpWXp!>CjxtG9y4`gPaQ75r5@&R2DvB$bb1Spq9Y z(L*k+Tcx5eJA6ZOEmb&Aip>`d*I-8yfFkq!BEs&ezf zHsaZn-X0f@og-)wZQQXOs}2vZjeD8T@PnO}D%%;nW=E>=*!_mXZgT~qVOMZfHt>Cm zUC`p(W=GJn0(Ko9z;4fH*v(!T*geMy`m0UL7rW@f#tIll8(bB`t1fAfja%5t{AUkv zQ6;O0hi(G@*@IRHqa`92y+%Ty$1=&{w+RPZ zo%1qBB1~{zmcnVOvQ*usrP|pKdFN$wV$OEsynH@R!E$8&LDIx)mxf=mTrM_w?wN}k zaxQdBHQuseS?;%Y^jcx?H2ed_77Ay<$vJ2jO?zuF9r88j zQq9R*vZp$G><>75Y|GhWpW^JXyPTgUyIvQPv+w*g zajRCeH74PV$K0inOugsp_&995!Pk0^`C8)&5%6?Q&OUbN$P6sO_;2nsj*UAf`?SOE zoGiR@7uAV>mOQ;bh1culNws9Dt5wcYhtw?oxMs%PkQ`9jd4O(Mv+wmR?i3}%xW}Np zNoh*1c=$@A|8q=&*bz+0kH(M{H#lFH!nX^V9D+12!?K0|%;xr78XmD+bG|MOzTQos zmzfcAYRfx&CZ`@~LToPyM3l?@V~NNzy9#?-^g~P&6W%$6x3=we>>1weumfW@7kM^I zSNZ{FN@+)<14$1%Y>)J_38fBa7(ouBag+1tUVI`R-KTSp?vrMu3+~Nstt$3JoORZP zk5!4U#Dlx6#L2N+OQZbz(b>$S`zF;0aM|0o!VB5Yb7n^OQcTgPAx-6i6bJGLM zMvVv7TaQP9?f&J9k&87aS$@xx=#>H)+xI}WCuK?9L0jx|>C+B2_6$gwzy{vEn#f^q zlWh)Ljhj;Rn=%s{iUnU5;G>_Rk7mZy#|v{%z14VCP`y7o`)dWLM6m$ghmbIgXPR-cx)dXckLFKe|dso+XpmQxgi6Cf~+c8hoV25(HTA@czvv?bek znY_NY7Ea>k^=%dr^u}9EojOMh9sYcP2lj7mu;E;<4K^Gaw84g?vX2MO^! zycR>pyeNjg%Q1A=`!o3kvxA2#hDPk&Io^xQN8=cKVC#7Z4eS|91X=HQJ~_cje>ORQ z--vh8pM5f%^x0}WlwFX``b`u@aWyY}1FQR|W--Nh^*!M>egHOLik8+-*4f2)ZZaHo45_+oR;&;=wQqx@Y&&sKegX2qfXx8k9yR?EIt{r{j{cv?;&F*NG!zw%L^ux71 zP3MPedz#JHRe9E*T*(QQ=7Y;Y#KdGz?M17JY%D^9Enn-N*h7v( zu_2FKd?tdCDMzL=GL4M6blAD!ck?SS$^C*`lxuj|_p5%=Bcz%e-*_Xb5 z2`^B0ly5VbS#&LjSz{`)+E4G0s&(=E>r6_S$vVynwSi+NBB#y+lKT~h7mD3(W;Z!u ze}zrS(ixNwIgxCP`>CtKArMK(5IEKaRkqi)okPiS9EyGN<~Lan4MH6exryar5WB^M z4CBF*?`au4%yV`w_|QujMFs0046$1_{!MYRLkCMq`Pdn}6FNHlg3S!<*PQIYNuonX zU&sV0+9D&5ik;r*kF^AII!BQQyDySL*_a7ZD2@On2UoGcTpTM7wcNylVbo|YtsPfZ ztD1Ldjc~HPMT70ed3Wv%v)N&YgT$HBI>OD#p|UJ0wE;N?PgR{xI)m8S3YCOX{>gFB zCXUYZw9V)JQN4?);Ln{;wWZ`wuk3?*Ji7Slj!wsxdeVM6VH9Co26AN7t3!WBpW&<= ziKCpYIqt;Dx;wEF_a<$1HCa&|ro)Mq1!uUo1u8!6)O?FYC>;AgT4Auj@q$$ zt{vkTX)cS?bb!Gtvki#YZ$YK-I>OB0Jq@LDS0?6~Ko7@Q8DX>M zs+ZX@5@S|X>x(;OaGfya=**oQ5A`~b9=&`{--9T2Hwt3lPs1w#9o5;RO+5cp!x3cn z4siiGyEUk|&JSm7fAeG3R`0Lo=(@*X)&AC9uU6@AeTDuyn2w;Ebb32%`f_U0Elvnn zjyylU%MW^V?T^r^4#j=q{mnP^4)u11@%12%>+f(s*;IAo8v1#j+fE!ej1#<$zDJ8} zaV|OtKl3^sT^eF>zF@KJw`~p9*QVC>rls21dFG zWy5cNFqGYFo-mu$H=a-)ixH&>#|p{PlhtMI^3C4qcfwi8E}ZFUcj zs{>6s(CdyqL*#u`h(ElNKCv%O_~8{Uwc;s)TT7=1k`AW`j*4@`rwA63UBa~i5*NpN zvJ9Uh_#E68>=Z$!v&ZIGN0yaO5v&dqI2*qE#Sg`FL{Vnp;dthM5&kSehe7lrk`&MaaOwYTw}j`HF6( z`S8Hp0Nr|0m0zw^cKMDz1kDoTkA$ly+MkE;+K^FwS*M2d2(penBaWhO)W}_v$AJ#f z+vG$Jru*pjyaTCV*i<`C!&$BedDc)G%(>FUW;1S;qw1FOJ@>C&ICOVpextW8n@C+O zY7n8<@^q?y#dS_fKt)Qx5?uVzp@ho|7q`U@h9e*X@ylg}WBl zi5KnP>-NYeR*UxUb$jF!t3~_w0Wa(Ck@oL(dnA{8>)J=*rN&`cYb6+M;Oq8CE>92D z)Yo2%;)O9M%lgp$Rp})Xfb9*JOmu(E-XJ>Jg@!^Nhf2ADgx9T&z>R-ur-^#+wtCr* z&g44L^fObvI zp{h=9?k&rj(>}EDPR|Cc(c?B^>w$c+xRd(gxIl7rS>bHXWr~B=BgA(*KrEF{ZB2suK@1viXb+6{}C<<&UUGUch zCUvKKqmwQ?hdYB80cVIg0#77in_Mph;uMz-mA@Zj>szg#6k3V{2(gf^E3df@*+NvR z+-403{J-WeoaM3db~&TqahA<%-w(~pK<0Xr$%!%w^E#* zqFQwhs(hH^EU`0L8O`0v)tIVy91>o4C(kxdZZbG=u+fXL?j>FZ)K08MLDs$4A8lKa z4A=z6<)dv|GZnuo3&+T(`!eoBba-T-hF0YYr3V=uyN>>ui0$3iYT=`&OH1l6 ze}8I+Rl;RYjqYpou}{wvOo6j`4Fc>+fgz_x-EN)D_j$FGi|XYqE>gx9HFtkCqHtD5 zO)8}}Dy!-Q=^S{cA~@UX(LlX1Jc#;d(R%ubTD#TCo`i8$@6Gig4Uzd=$Tw)*fR)ql zuuCjxN-XGe-BK9|PewXRX16|dU*!w90Q<4^qf_QZem>8hRc2a583EI{W)LUxGHT}4t{fY(78_|;y~Edq&;V0} zc@AFZ-cVO5rwGAo^4Cs5ajtHlAZ=sYg#JTB0n&4o*V|j+TNshSNG=hI0TPTz2=q< zaF^@+a!6!V_-ssd3P*-RNCd^UhE{?tVYoSfq)}QEuoW-GSTQ(H(V=l+21!gOK5pwZjRFL)knel2isg|O&wM8~}qZ^POjxHm+~SZEOu zs!B+rMkDu4#TZs(Hd4i4{Lp-VJ2yDW?zD|2Tl}9{7ydDlT8A{W(lIJ%~K0}O}-D8(R~B};c(%e19;=$dp{smi>14Zy&|y2f5ddCb_ z`*E!r(MURLb?w{yyv5_5y@B80%ja)ZVqWk{Slg_m2LcVIg> zRiFCj(5vkhuU6(c!~H2v=BJe1L^0eWkpcCT<}`atU?`C{q zX<`P+BI?E^bT}Uk#%XNtu;|L8XI#fo7#f4W)ioVlOc7OMZN=gFBkWl4lvZoEXE8?j z#tu0dBy*DTyGG1p(?m*ZLP=p*@&WxrssgRtimYhLC+fm2 zac=`W#v!$z7O}wT=tV(8wO|U>iU4U~^JJ4bx+){ z)V)g1W~Kn3ZQ-n~x8Ew$0*Xd&i|VvcH6@ zqOpIeI2Hwl@W}A#V51D{r4%yhV97K0NB@!+>7MA>P6bovE{d0{+NqC5o0LbRGAS*O*=qo5DrMb#q@ zdT58GK}e`{L&{r|I5#zCxuuybNIz^I)A0nh^;1l#Wbl(O94y^@3Q|l4wHvs;!M=5W z+NN9O(n$Ju)kU6k*!icp7U#|l*!8NRtSj{R0BU^d(g;TqR~Rw- z(rAPGu52}5i_4u1Hr+5dY567EfkODKN;BUrZCbwUbKTNnlycs(xQfgOaExRy$(l{h zYQ$7U2##7Kz_nGZGDFWh5HT z%1AVvm62#TDM!(85rdRrl zv9^X8f>%M^$15N~JeEGLMUQ!AJJxJ^YZlRv9rhvvWj%$;P$}#4)FBt{iZ=&_3ZJW5sI_?XYP3-E(vF(2wG#!hDoxz1ynQmW z(2<$`6oz$kvx6<5a+r;V-hd&8ANH5dh$&bF4TE0c5;!waAZtt27O>6^$G}O|t_ln@c!(Aq0Q}PV8Hi&&jgkK zW*$`j&=UwMpR$$-LFH4{G9jpZ%33A_l}}mAgrM>%Ync#KK4mQvg370?MVr#HAxjK{ ztzQCLC-5Cl*$IUyO7g%N8>E zmZy(cxl1D}?<318pO$WU`ct39vKGPRRw9osrwp53oM7mBP}ie@QP(tk7}5cO=R#^& zP#G*2`#c?D)*N0wUA3~s2lxz9F-&+Zth4*5mbcLho*a!8aY0iygG_)xxaMBVd*8qd z_bmyBkUZ80o!24^c0Xs!d3m?r4Bbd1YecbPh9))kGLt{bjX6o>35S>KMkg0tE^88t zyN@BJkBTsN|GICtzOud%k4SL&o|Mo>uH(?fv0wwe9V?K|vtix)7I!~fYS;+i}B-;>0xZaZ^q9nBz-9AyofpPNNOv^oOfhcijnYiu)PI9VWduM-+dM; z@$u#xj9|1G@8iuC=BHhrhDs#|klk9?mjy=yF9?WB24Iv8C_mi!wB3GmwA<6iKk~Jr zz7T89=7?PpB99<_M>>3^zC${CUG+l7rFD;B1V4E5x2CJf$vK zantT7zw>F+U_z|G3m8p+Ta$ek4|MGxtQhs-#I4%P+_4d_R21BbH9y9`@GaL-^i0GF| zHRz2%W*eF-hn6?742RHiQe{YV-;EC~X9m%_{Tt%0+VRf$Ykucb8I3tRVsFsh`J^kN z*r0Bzv{!w2|H(U?M?_gXB%~QLfE;5mlJlrL%+|)?usYE&N*n43+{Dh zNK>1&EhHP}Kh94)hB*UX9FxP%*M4c!PAPmlO7|~;5j)0kd+8WM?sT%*U2-hmGJMYy zz4~rOoXoStu3Sfv_?m4z8h`Ju!>~` z!|L(**(8ikgCyM9jA%_11Rd}OI4Sm^<5De~y_+w#64o{t|DMgGWNvH17> z6@>2bXICQS!RVXZTNjLO_w97K4=J_K|1LLNZ)1wZiLO>!uX!nWgq!Ll5mER`;f*%CtGWPj!2S*!Z4~VI2-a69%UlL<+~f0 zy1nh_hl+Y%c1IG1IBA9%4=PgxIgjxXDOLg-+-#bnE1)d{9pX7mjeVhmT z@mGi54?iKkd_F^e<2FiaF3gnvt& zH>z;EQAJ_qx=^P83s+czyBkre|JYHHNHiMVZ8z7Hqq`FrW?Qz@3DMmgiH#9NzA4b0 z&;8)v{Lw2u@Eafd?MI^9h&4~=_e9abPd@ak-~8+s{^FfqiEg{~${GAGDt5a|#-_%N zx_1c-)3+eG7T{CCGT`kXs8X1GTh2rF(uc`A;6Y4BO@HA-uleKGJ^cFL`3B&0pr;?_ z-J>6hfg=FVF1&!Z+-2Z(%G7e;(G>t5|8;horlb(&9QEOC|#s(r)#+kam(Gq@834X(t&%+DV3x zc9J2aoum(Gw?StS=S^hQ+f_ud-5zDh#4hu(WLjKXRB?9|saVynjIs>w4j@aq+lDOZ z?qITHbD(}%vgK|@(tw$%U*U9$sAqyIqMix5&oL1G?wjvE#%#o@zc)JVBI<`{okbzv zgsPhJStK62l>iId5Q(lHO)pANBR5(^eTBuF?T`i}Rf?$3?+dD|d|!}22WaOTVkeh- zHYrygyL_$0GQ9q>Jfc3V8F^nYC$AqKc(H>jkEXw=Cl*4Dr~4i}G5t~S3Y@|%8`^!P z#FBKCHj7h3Rd{y2O6T%;VyB#q8z@)tI+49<=RA0mI|r`}#{+u!EcbX)yb z>EQBs`DLSr81N*F-hXuyfrHw6Sr|PJ-s&C9!i>mAuxHClae>b8obqJ(`4^u-ULCA( ze~w{{J%Lp_c3$p6NXo0$w)W!t8vZR`Z_8Kc5K#o}GRPxW=-lLQ&=KS4n59=U&*f(R zt$c&dvqvt_QPnnEZAR=#pIzLaGmI7W^w3yFljo z$9X`GvK%|UAlqh#7i?3rn@fk^1({YWlU=$jogWK;bbgGN()qCtNash{m(GtWAf2B> z-5Wf1a0kk*{T*gVcU5uPeBdEF`mAlc;i^~>Z0>OcytppH-=WrEd&tqmAahcG^PVP(tv5=u{&fw2zPU= ze53(bDf2^G*d~in8RxvS;|z2seC{UZPs7bEMh#uPQOK8#a2#U3c#a)x*-yjatN6@s zDb6ym8$vSPgcb#b8?wAjM!p0R8JbYa@L2{U{Qz&f`ogocEm1d(zY5+S=bD%>vFLoHlDqRy=knn0kuA(2PcL){JJGLNl6)tr?9Rsu>Lz z){J)dGYrqwPS(B#Cmh>nAnmTjsrQ=9ytbciZ478Vu`;F`%C=3nv1dATX8g?^tSL3! z0HiY=wyb!j!^k8Wcb>!Ys~5a!Atz{#F~i&tbTn8T0|L56IAp>m%4OMBU6E1BZdYVz zkzC%)IAC<-1r>x-Fh-dZZ5AA9{Gk`Co;Gnue%iz<&&zLkm<`T2cYN2JaWGBloQXk} z49+kWlEJHUZ=A>0;EkhdFe6{F)@nxFan@VSh(FGHs~K^~S#LEX9y#l+X2d0Dz158P zRx{$2qiS^3!H1{3yuGt=(YY1|j=v$*99OGPb&v(bUhHZDbJGKJ5LodaNy`}Y|0^n_hJF78walVUVSaRW=pfM!PkHsoy6Iw^= zk<2Sg1`m}SOTZb1O~?gQ6JhCSL^&A5LTtOMbW(6H zMw^{=o{#K9H`thgfi0cR1#DqXNOuY?Gk&`!d_j(-8x1(Y0t>S$Aysxed5pmnJ$c2> zcIbUOlBkyh?G^Lay~ONw;lA=^pw85_%kl(ZdcZIv$ebaWs?NGYA4okA_dSp@MlJ`^ zNNRV23ky==2IsN@&CK^?hFH;MMN>pyW6d&+lJfxw!=_oCw_3^2WP({BM}{*CAVTH? zXO1{jw^=95IGPexE@GdZ$A#3v_{})=g-0G3zp`y*D2kIh_PG-VNbLsbl8c66-%-Qm zYy^u}_)%u{GUZfD003$U&~|n(o{TI3!H=Zlh7C-~ec* zb+4Hc-V(uw#`%#HF|^R5CJJ=PZjmO3E+OHR|5w@G=IzeuN=O^>BMDt&+{XEloXM9C zb0i5P$`CWC-t@V;Bgs07pqXulPl;p8j|36Hq&n9exRD>pb^wsL2Y~#A575=sR5$7x z+>!f{{85@zi=dw(Dvu=~6=Nv>(<;OPOOAzzIopATgT?uL4+@A%!FVq>2)#3yP+9DuSk|tp_cn9g11(ZwhbjzwmG%q8z3sjU(++(mM^!J;YUXhd3V4NjsG-_UC%3LlQ8#I%+PsYqW#lBr#L&vBZi zz+mmFP`X-(YXgKhSb+}*5+>Gbvbgj6+hj3?!NMDydfs3G!63284q@RzBwHjOa*;v` z5As1n%zbas*yt=8n~)ss7&<4Ad5gwo3?5TkG}wfDP{bOdh1mQ$>wY_n1`5yv`YjSq z#>rHW3N$`BTF8zF8>XGf!j=3Hpa78xx3&6N1wj=7m~b7ADz;-ZB%m&$A&Q>L(c zghphlaM40riufIpA%2IXnJSndBt!fT$q>IoGLPTU(SGb`bLiEGVZ^{gi!53r8KQ+q zhSo@i)<}ldNQTx(=B>eDhNFd0GGsZy8N#?xZb9*r6X{+GtZ^txLPYRb4O__}7Uq;I zURgLpI<+3 zC+XMEC6a#qe2idOzkWVS(yyNnk@V~5S&~{msqB7|GWCpGJcFqR?ksC!ZtB66XUr

<4Fl5x(VYCi~?Jd!YG)(C(Xl$KRUWL_bFDS2JR${zCW8=J6jldb&+FByn`4t zV&33owl|aC??_mXJ`hOf+nJl#j@@0^2={8$4UCw{?`!IWa)z?&SqCjI_Pk!7a*(Q!~01 z-c<7v)0A;I&WHJNPADbzic6N64+WU>0Mn3<0+u@>cY5birwzKl{63I*&%qf!9D2HwL!sm&z9v@CW>sSZ}2VM6bkKegl1=#8QoJ|rpLej9@&9M-c$zXG%hv{Ppw&*cvHKAa#`3Oxlk(N` zc^-BQT^{FF2^LN}Bxt&V1Mv~n>}|Wq z(6JC9dplFqJ6qJd!9tHLL)U_r*xVqTsN4h2jSfAqr0antT@NhjdSFS{153IdSki?v zS+ZeKP?mJzOqOgJ*eUZMM3gx!zW(O<*=k&=R3e7WKvDm#NMhZyWNLfvUkP7ANEi95 zgU^MxHP?h{sk@@gHVgQ(dR3Z5R+^mDt;5lMFPwbkkN`mIhi2$2iy0G0hr{V^JR zaiH5<7vW~fnh~HS>xLOiHViYCZ01O{pDi0VOSTQEmh8BqgDTJw%~-Q&#(atr)%Zp& zh++hOqX_m<+sh2smT3`;U{b6t0e6`rt>bZSS55+%_@l*24bQ&eU#j?x@oe3lmKmvG zBq@wD+|@&x?c6>Iu0!Q{1p1Ui`^Fbe1Y*c(`-g(iXZ3Sc(KjrN#neF)a7b@ z28tlgkHNfSLHsQVPq&pk7gibr!6V82f*~GeQKsik9OutD+Mby}+p7m+uGVLO8b8kG zfDOmPJd~5iS=ZhgOLarQ_&(JHNJXA3iw;IO&QDWmj-IA6o&x@{$jTVomd)mYi7iwZ zDV^vS?y#gnZQ>jR6cc?Nx>5OpLHt7UKh-W)K0kYkJvoA@7zb%|`I5`tMOcfV3O_yb zrNycY@JCF>Xrdd^elR`6{momLdy0m$Yn{=V#}<{Z>0N&_SU{Mn5~rA=~Hd zrP0%PoR{q)67oD5L2FqgAtl}CQa<>LcUy=uGvi9 zI#Uo4?mine%Ab7^p8^0eDV&MiVI z&y_@c4s_rvbxZS$RqYjgnbYT8GK%;M!a-*s!xSOW8Y952|Na|+K30)Oaavlf`SG0|n)v+hK- zjYb)>eoti9te}7~DKN90dl)D%lfCMb*+$*q=BC`_+qTJ=&s4jUNx{v~_eP`dt$4wI zXX%@O&-wnwOh-sMO+ggCb(q4lS+~#)F%8C^6~=C#-&AZf-Xk2m;5+Q3xvib;)B z%$vD>P2DHewabcP&v@7*gYmHZh`wqb)#U0pAeCPmX=TQP5XikwnLCi)!L;3bj+1qm z{{Z+d&S5oPEqy2SP7dZJE<`q*&zEPrsFbb!eG@Tz|C19jyJ|I)99xS(kX5Uhtg@W9yXFOmb|UvYJVbty5Mr$+30HY9>0iPFc?+$JQyU8F6g=Um;?4wTZNL zch)KKyn4j!*I}%a-C3t>YPmb>lm(}8XMIqR!p#f+($yknS3|XSOD1~H-ge7qbBF}y zS-_^&BNXzTI3Deb>FUt+4MW^fcj)a%-;9? z?S9IbOCWKBUm9@P!em_O#fVu!)BMMX+5gESX7BsQh*^u4Oa>3I2k&Be|1o0rRoa1Z z+yGA)X-Toyl5lK%QX^)^EdjxevNPgF`HvB^s|VTQ=CZDc+0_IOIZ+l7v%gY=S#A%G zl;6F})MN*s&jUa_V*$XwnTJ{4{zSCVDXW>JjZRt3ByDucY9?u;Q&uxc8=bP6N!sX? z)lAYxr>tg@HacZB6SdJP>zSmDPFc-}HcI#g#-!;EaQe$wvb{UO>3$t4KIOe?AG30o zMpoX3SyH|nX8Bh>i)AgsEUiQyW=WZKg;`FAnYHVs)73j6%7`RPwOlWqvfwm& z=_jYqOQ&pZ8ol)AzN306(IzYioQM?SbpDg|7Quxpc))Y8tSFAbnd~btyg4Q)_>@1hASzGZaD0b?Gu35v5 z^4+>-ZLp&GK4ac455r9LntH3Jd{yx)$E9h(1%ZyGVshdG_+=Zs_s%Z^HY5! zxU7cO%ttl%X$`IMHg9d*HESV$&xmsQHESc6u9uJV;6!aWk6hmBx=Uu+eQPU4BDUDV z47Z$-Dn}x^5Y6D_3N>TQ$y<@V4)22!z}`(@#IY_O4%N#x0Gd{f)egWS>@fFY9fHUr zHyz7W_}Ms~I|q(-)m?`kJUJW021iT2=eI?uZ&W!;A(7KA>11=5+$E~Zg<*V6T zoF%W;do|lkHWW|FdVys;YX>JV?BYhO%sqcKn_F&&{Df>KK7k{(0&PKdke8dc6XGn_ zQ3Pcf$$LVaCA}-hSt18A2UgRkwWJo8v;ECextuL2E@xYsF3wV`d^F*Dw#V~x?e87A zp3P00!jWH*bS5@Dd6AV*!@tBuR$fF3F$52}!2@=JIvymsr~DyY4)Gp(PT+NUWF-+K z;we6|vK#!#Qsq8^TpA)PeLIBBsU5t2mbc?&61$}Bkixvw-lnkFR8Z&alC~ogE@`{n zVrTI$d(64~y2Ig6PKO*z=xIe~$E@d~wj4dVv-Keb`w86gO>O1T1#KzlGA zOjPlmvL7Q9{Df@Ky!1GI|l3vNfpGpe$3E?PR(DuxPT{Ha~3h=u{ z3qAMHh!5~-g)7=3u2FM2EvWCx<=3iK8*?5X(v#dSP~pZ7e>Gd(2E>7f_aL{M z)uY6&X8U&C%;s-<`^bksU0dL`vdX*!;?G12r|@4=bk)#pY|qtgY;{zM;bOCRcbXTug(p|k~Nq6;{xM^|Un;^1F-eQYsw4{U6 zlIh^#kiqFS+zDO2Ij!Mlkgm662H}b97Le{2%|cPz8$Bn#Hmx)xAZve{QJGP){3|d) zLE{PpB$ICUdoFc8M5KSXC29G*ggepDbMs>>se2z?JZ7WUyeE7**|#MnI+;c ztEz`1r6+PV7`Kj3qfB8&R&&7UkbWNGc%qc0HQ=-rMFO&)3S8sYM5IF^qZKjzz+3;Z0~1Q0I!z7S@vuhxK?O zEkQlft>qV}jo<=){!Dj)+S8xN1!`Bga35+zr{WBiBHBj_Mi?-TwN|KxyR5aU;0XO= z*u&*%p0WEFK>QiCMsRjaH16yesaE=k7$$|=ou4Cxn?RU-j*MJ;B{pma2Rw&>&0E|e zLHvN$kFD939=qmt2D~O7;*TaCF*&kg*;#qSRk;a^rl|?b+*F_rK|I@eFkvs`LCefh ze}AE)h~Y?_U4Tan4Z(R@o=6ccr|CwD5IRjkQbgitT9P9EX17Zbc9&s8ch{k5GLm-J zp%F6%O+0mc%4#OL!%SJtBzKr8tC{2u zGi5cC++n7yW|BM1l+{dfhncdPN$xOHRx{BZX3Badxx-9Z&4@b;d#zKu!@Ne@R^A=v zjy0S*{tH&_(#XntcNof--C=&wXR)k>JB*dc-C-!RE_ayeFtc`dnCa@B;12U8WATV7 zcF%*ZG~fmax$JQ`;nVqb#$5KMYzjkCGT?GJ%CD`UHa4{tu-d8PX9X$LvRatNlyFYt z4s$E=!*Fv?QkcervB-G&spI%>f;QWvB?JGuzimBcr|%Ab>i9dBpE~|F&PSDQz?r9S zCMki1-CPg z{d>+k2FsMZV|d6r#&2^4?-&%dA@7(X(u3{$JhVslgSIxK-{`61{)q0avXXm8n5|PVaGoI9HI?|q={y6Sr0wbtI}oPEyT=VT{2O;Zxu zwRZ!Y(A1`oHl_AGyK|vd!Dr=i@92BCzLz&{$9Rs|aYsQ0uWt-4J=GM06b)J~QT!97 z*a#FNR47s~0&2jjQ4xX`3{o&i!3cfWdnvE)^ZotiTz~f2=U>ks?Z$| z&;jFdp~wH$Shv!z!o*?TMUL(gY{!%%xI}&#XT!Ky26DWcQX5f4tzsGRX8E#>`b5vX{-g7#GDBfB7z85&R=-*IY|FZ#a~qMthFQy4eV2+n z-^W$>o$KcP5$%M2_ZxI_+`oe`MrtW#Mu60h1vbqV2Z~s z*@u}nQ7btig>WDsm&J3#)kzX@9}TCI`TCxvEo*BN_@uC)5!~p8SK%)_2g-sJhyg{7 z(?F1IXg79epq;<%`=KLXv~VUPjgZlser#=+jnYbHx3rSkGR-==4J0u%VvJZ$g)*$8 z;*C&@R1d7GVvnS8B4ZI2S}dFm-gXP85{HF*d_}Pqsv6^ZO+U#gS=!89x(~8`jQfx=XR^6VSs@O!4Uw2~@Jx`;l zFVB#wI5xQymJT;Gym#O0}hUIJ_A|nK7rz zkWociSf!(ukBb`6lh@c-B;C@vDT<{73(}(%jigi7Wo2fh2xRi~Qx-Ut(_9FgVth$O zR|%ZjOxoTJJjV*KFRU$cW*M~)(pU7>s{{Eu(O##dy>-SLm~~UUZn0x|5j9n&LZ@Yq zmIBw(PT(4vL^?F-85DJw;-*mKW7zTsM?LocnG{XUnNnyKna^yJPr)EI3Sy`1UQj2P zPJkgup^-Lf(mIabz$IW>31Jj5C%Ur1+lkN$2c#U1ik7*)2&)o?pze=p7NfWw(G@=M zpssM-2SC#3ztN#2-G(r*>o-B1Gi%Z$$rAt5XigNNRSXHA)QYO18Xr7hsm4pPI$R5_ zddQ-5+ER)b1>#@^%^%p*>xh1s!bj5ng}a(L|6aD~UhbBmN&*z&oFwJrS%XB{%A-BK zHA!|o_PGyz;hSGMe)!SkP9{ui&lTKz^uup^>-e2}Zy?x(f$bqBkx@jZ~}>1QiuKD|+^BZIZB`9{F}I!IP@kgVwXrWG9|D^6Gt zm=!%?y&}Xq;#YCw&dYgTm4){3s#TG2IIEBcqL7$U@ssIcoF$Ngm3a%e`_jRCtt>R$q)O1QgwKdkJKB+hE%r)P9k%yJu#ZC?NB(gDIW(Ozl zI5H1k?<@?(iA$<=5od?w!P5hhB_e=YM3sTf4l|B6k1kAwuAx{2>adQSS04%ZypM!~ zc`WVWsU=#+QYnWurwBA#qV<52xBaByWO5mtc$D2!gp&ZZ1jpq@GOn$Q;KacMCw*{o zvB#rru(^ahynO2cC&k8`*9}J=*674z-xkNoGYev-R3+x4Ofr-4OrmhPT?7&&_m$v5 z)O7QrlN2nWs3)GeZ%ZaKSyclB>d{RV^QY)M$#=r>;K_>EUFCPQ< zujG~4N(`L#bf9=o87k^xw>Ha7?a|X(N^Qc6Ufz>(5;-&riKNOp%PUy9r2LYHNfO{j zZ33^~nylHGyjv14MM(HiD5BaOOrc}eCbIX+I+)}ayLyPpU!+LZ?3@c(v!x!QZ%I8w zu1mNesvb;tCaD(>QG`yo%a?X0GObOWJsr|Ca5T($LC%L!J8$z6s_3m=qGMXrc}l-_ z7jbbiOBQi)?FDLQFiDC~hc!^kaJ>$DYb9C={7%x~lyOEF!MtU--ltt_?O;?0bG&A> zi;;1=eF?WoSxyeZg(@NuF1rJx`l6}p(u@7G zGR@hi;F@s@w1U@6y+yru&Aq71zz8lXmkPYdEKI{nu;0b9rIb0dCl{o|u8xbNVRPR) zG3&v8W;_{t)oREV*ftiyes~tD5iAQ?&KqFJFUU(KS;Z+!QF`8c7#b9gk+6c5e{}JOS5S4-TveKbPKiT6L3eS z-;MoDpiyFHasjVD+fqEkrCWIxs4_vDn;CPWbdsc%W2 zW*t?v3Ki1g_C1c0_CJpZoZH{{@qQLjmR-BLziu;cIGd7Pq#?V=%!b5IX_{zFPPIGJ zGi$nQ*R7vDPkqLp*`wn+MCw|68?`)AKi7ys#1EWF9;p>Jvs~cZ3Y%FjaBhXoEEhPp z!e*8WoLgZt%LUG@u$koo=T_Lva)EOzY-ZWOxfQmvT;SXan;8n6J1AHE{L1o3{kpu; zA#m>CDgx(z!`{0(vG+sZ9Pd{H=iVBsSk*Fc&fX{j=XmF|0_RqSnbil*t*qT;0_VP> zRYO~@mkgX+;ioV%X7#|i6~2hg4nkNuaPA)jDb%u5;M@v7x01lQJMI|%+*2GlcTjN` zwZOUGwH_05aWDkV9Ul&ynZn+oK=Cq~JR-%;^n6s5&be2}6fVv6@u43Tw;w+oSV zinr@{g@-A=P(`wt(C$kpA}u0P#K<~y3!V;+;ABcTvfzoS1odWcD{hci>0}~=EK3A3 z)f-L$!s<66uhvBHYS|er94cS)YT4UkE{WT6Nu&l0B1F~+Kc^d6_rSmU$hu(W=v5-? zuBk=V5f3*2g#+#?$WFx&S@*aX%d}L|_3%zD7FpL}t}4QYG+ANK{-SBA(3Bq&Ypq1q zo$}vBWZk3sjhcP(>_*m|dOET$InBtrla(Mf#s&_sg2*}xZ{tl!3nnZTu$t?6g0P|) z@s7Yriomy3Mb_c2H3R=-zOEX(b`S~@Juezr7kYsEQ&1LxUy2bdLVwOQvTm)M$-(_l zgYNMF9WXM{_`Ln?R(X4ftb@B3QD|`4fcR((G0=;!#uA?~vJT4{8`!ivijp>Dp?+B@ z--(K5C5yYWlEvLw$>Q#;6yolpxtU{pyhBCY9X5f}i@OW>Ul?~spNbx@F7A%ChDB;` z*0(K^6!*bID%xy5BL$fIWX0ncuc)yLe`xm}t@mDwL!i#%>4KASxJ#^P?F5~39Mgvr z*Wnx-O>CV_c8ywnV_+WaT`ARCAckAl^%a=6a2YO=a)E}S71;t0R}z@VXdZ+(G1&s2 zFUmC=-SsGN?;nfMyd$su=saG|H|sl9@6J{5+?9vsN!l6_ znwMcW^w2yRAU!ls#z$DHS)J{a!oq90OlY1U;ziRsEGSPQy=Hdi6WxB1t;%eX)u6oa zZG!UjZ34JPetXHFJbl}O@)TnNfAc^fCe7l(cr_sH%}_qXiSX^!SHKfqPp z=l=sl(tSkur#%|KRno2suY~0kMr}cWr#(@4e3LL{g~{+b!dRb(Hv-&<7E&HYShOBe zf<c0j__byN))MATR1YP}X;nYsgw|NnBgRq)bZm5sozTCmG`isM01gIkMfDvw#FX*%W*&7Y0_U z5P@PsG9i6CxIk&!xe&S_g~+`kl0ztgftHuba6#MBY@4R%c;xOMUxMj$gusqGl~aOY z4cz(c?syJP4(9Q=yiK>p=swZe;?aHTwWfM@tq}CG!uWlo^s>_W&a>Ucvzsf=z&(Bz zUlysvR(bFFbr@~PTVONhC1rgD`_{->bcrq98)z$O05>BRdhmb`kG--_0G!VeRXLE-RaUw{VCQ4NH}ag3gRwlb7YRV@WmiIgos;YN z7_#}=T$cB`-bb_@2pLMAFpo=^k`~(sRk9e9{L?3D2=hDc0euSdLyy9PDV6CwR+MV4 zw2-KyH&J|q?+V1mwM?EaouWT6pbysp-vRVq8PK-|lq??52T^}gY={7!W4f{qKBO!B z3mK1&w&!=!iJ(+uCk&|C!h8@7SgGyEhi{Q5Gw%( zVkHa@|1)2@kEu0kme?SIjnr&2sWP)!dN!^J;eI6bVu>bl3h_pfYzm^XBD;lRu`y6C zFuU6<>yB9foZTI_05vOmz?Bs}WXy__gMW%GOb!0uSZtHEEwtMiDB8>FPM7V@lDgU`oeW?-|vAZqnp&}>+f;aEElKE0*cxU{zAMBfa$%=75 z7dE;4>81TCGx1dE&t`5|nLXZN4h zc&1^9Yqi|&6*qNcyI@p7ZY7;nOZvcPhCM6Eb>@~Eg)7hInPQa)wX+^Jz(v8mj+fbci!w7*_Ojh40&7(GfPhJa_$sD zFmtC@%$)%BO6a6cA4D(tobiE!KKh=hEliQX;G77S2~zvl^U8T1q=x#mhp-t_L@|fK z>+3Qkr@@a3QtMlSHk|4O8w*it_0^-`(haQ%vX1h{VmsJ2t;GeIh#)fdJb3v4}tBhMsS2CXoE?N z9)VF-M5Ejnil~|Mp$LXTuzHkUTOKubktsh} zZc%zOOGoJuLk4*w&7i-F`yME)M>PwcWEk<|TdPreh1_Af;8>ltJT+S9n6yq(vCZL$ zEHqfrh0UJc`apq&K(qK{P}m(~CR536*MnJ0w%057riBh4a0$>1Ii|(xeweJ4Xtum= zYGzO4;7R^WyqtqgUaobvI?)`Jn=!%Aj}XlU3TNeNY*QeP#cB; zfv}{j(}++zRYa)0^`fy1^Jn^B(^s#Fk!pvV->JB(Q}Vl@w*Hg+F>IdZ|93ZIhv2LioZJjhK3@rPQRg{7_8H8XebKn^cxzA!#e$j zh9a@7p|KCX;4}znC>G0F8b6(ehN7_$%vT(Z^_i6U6r!=d8&CDq^ptH;@dxtk2uZ7& z_sa%k1xoNQtcnpVedPQpAg9G?@$=rlhIfv_rx=0|J|zNOHhfCz_^%oHRiy8U z!O`!!@F}dk+ZYQjM6u(HmQ$fWAqN3*Nmzn+AUqFQz%84ysb3wbcBu=uQlmR)dp+3M zTy_;+f~?n+vSh==AhM_4Xh20JDm&1$z$wL0fl}H2%fPAO^ZeA7+4`rDsstv$O;m%X zCgNWGoW~Pz z<}AO|(}Pt%a%mFNk&)@^j1q(iAWKKa4z%d`3g2_^h>a+}MlpVkVhoz1*f5K=)s5z7 z?#6Uv6g?f85z9WdCAe8uKjYDO`0h?XSPUiU$Q1wLDhp|=l$o-@ddn$*%^-`inx3cz zO~El@(3Axh8H;g+ouM&R#bQ3xNH8OBWW23--SlDy`vFDd%LTwRXN|+0h9M77S`P~N zhl8cydl6EaZy7Zy8f7uaMj!CUs|TR#z;KyoK(mnHo(Hgysy8RKbHq&~@$9L+3zogdI;{@ug%;2#RU38Q2xmp&&SU zKD<@>EXMmGW4wHeKIsN(s0aSA;_jMx>2xY>RKY!TI)?oW$Js6y;M2)#nYS!xC{oNA zpWPU#SSxWo&X=Ls+PDh4Mnj`wS@-va;v9C3P{iXkJBa%tq!-2im)V<{A$s_C zIB5Yrtb(;;_q5SDVqN&u{trN7%Ul6=9^)s- ztwHpUPK9gu0MLVNMGpvsvUsJT9u%GnAUDGo80)&pRG#hP;~09iS`_x-#Q;WiF5UrK zEhLmY1a4gCfgGUzJaJTuE(ug4a$)9e=Yqdtn_uRfl&<1ZESfy0_Xv+2sAPv%I1YcQ z{?%S*?*XAAC?JH2l+h&qJDhaVyC6J&1`CIp7=1$D6w=|Q!9;KKuB7+OqGiprq<3Nb z0?#fAoSpRSwUW%tpChi?VNv#f1!fKwlESb zmutGe1X2lH^Z1t{dbzAKL;Q=?5y3m3(raH6L)U9sbj#O@I(p6O=p}j>);uS2#V72u zUBecQ$Mndbb0vq^bH$6o!m4qw1urn9VaDyGUY#+^mG#NjWi#!{u2}^iNpKW|#q2C1 zBP6@64N>-#sxD<=UX&a}+KtRR_LzeZ_p&Y5rywhw@GyY&Aq-%+i|3ySbOF9%E#VkF zV5GN6KLF|;UGqRKj{JfR8tdesXTF%YW z9+M?{9ROW@ui;B^w^OSVG4Nb4a7kx_Fc6s0Dljk<{xLl6nA^`71I3D18TSC_X<{I7 z-p(@v4QvAgmv$7Qn+G<$3Jm-Yr-Ak&G=QJrDZxMh^o%fYL1%No!1sWG%Q~~dz-BXT zOsoO}cY4sv^dcC*!EvS-fQ8!M?olynfE(%G5=&qvA zg@XDz3k4f)-$TGolN*PbKZ@=HpeyQljo;bKce>x$(W!XiecKuJ%RB3Yf#{VtnSVvR z@8^BgW8l{?20W7L%rNk0wk+EK3PuV^zZS!@G@zy6D~?~m!1;MB-bh^U8CK=)rX4=wd4Qt=8dGDtd zne`sXwRrC@F60qPRe6Nz9se$rI&a-G({ROjeDJv9l)J}7BMRV!L1-8JjD^tbH<{M_ zEhgVsU6XRGJ~CeEbCI0Ci%^osDYOS~ECioI$W!x7#((pRH?t<$Zg5eR1PRqHaEVPE8{&^~=cWnPgG@>d#;< zf)CB#Ml^Di$NQa4y>J8)88xGx(x2Xh!dpnJ=`J0lt7U9c?**ZW4aLGve(Z3s5`@UC z^iNez+hr5cGxICx$JO~o`SZD6l;`>7Tyt@2tt>1ECkTSHRfe*T|IjjnZzWas@~w@N zMAfq>4XcUn&6p|$6O6Dfn7|s!`h`I4_1Bd3Wx)eg0TY$Ly34p%*{Bu*{VEkMWo?TU z6!Dnlmw@qb2W!X*w?nSU*RZN$f(hFpLk~kJYqwZ&$-SyjRxm{UR&SGBGvA~0f0O*O zE3+H9(Dj#b!614AmrFfo3WD18{CckT4k!5~9$<U5+q_~-rL{hF-?<5HzK5K1yO7e>oJZhU9Z{fmL|C_mB4%^LTlOO%^ zOh5Lem+vHqRiUVnF4q^U7E!Ew@!Q4p+Bj)s@TvA^!qaIRK8$|`kqnNd4}p-$_%d5QS5C!475Uy0CJ(8`xZhc4v4KGJ`NdyD9M z6?!c@`dhVu?8|}?#2j5SB^oj3w}^(HdeJLhG~F>NQwHPSC3N}%9}`F3x`4!Ze8vT4 zDhK!Yvjlp4EP(U=g%SVS0(oJ0wvNT47~z@dHk+xt%@zS|N*kdK8^JFIHiBPl-5@J4 zff>DuiE@iOPS{{%-E-g=!yrp z?w%=^o0#!N83;ZNPOKqVZ82LmBmWk?(PVM#j*I*hTO<)L+qM%a&x<>DBGRd7gMTVD zGT}$ww`@7~;1r<-_XO#XW8yf1)!vn)s9ZW~Qj#JrY07+apYp-3Q0$t?v1=s#i=x=2 zpNLln)f9rv0j_mPOx0Z|4ORCGXO5BM!ic&eEO6P#IN2Jm;IkT5ctf5cmnM)LjJzsB z<<7b^alwKLTPwmqej)Ka%4?A3&na^OJC~Ynnh6Jl;*(DD%PsFRLXOflCpm;FiS7B? zrk>IH74qN&I@KK@=eFxi9^@sBZ_1Sj#JhC8 zB*>u9z(K^)Ic`xrKr#1X6jqJiEwRV~Swd!PhD#A1T)L7Xy8C5*CZ`;t&O})6l^Fui zH|p+>lU~p_EHM{m28UcN+&9W*?JIa#(s9d%)g#qpD~JL#hjoRRDmHVKN=H7McVF}h z1Q>-EMA6FWC&5F!MV#H)fW~&KmdaYH6X^44484)cP z^yJ7~&_{|B0NFt0(M&#{%3k;;RgE*kr1%Gx;@$A|K&#v!>pi!~8*u+Z~RD0Q| zYBw&ZwtG~yn-^5OWmL6W7gW1#RJB)EszJht!ZyLZr+d>;uRzNsO8(`fg+A)^{xzd_ zreQ7A5-VJKdD84DaFUR*YY7S?6j9l%5DNI8oK=CRF4@e`)}+&IW@uy5={7U8E$MWd z8QPR|y3Gu2NjlwThBhRfZZkvMkxsXnq0LBYMthM~O*lhak<<=5kyhQz&_*Ogf)#H> z`j?*;da0SY>@FlH0M`Fi&iJMGAU!GRY87drm+FEPfko3oFZtr|1|-OeJk^p3CFfX& zyI(RnaMboAFta))IK2C4)aUjgA+bPKJa1=d@6jVEo%78{L|tL#FwazG85S+fmwWcN z`+L@7qL>eUx1@z;#RKk`mO+B3!8ewha8?{iO528pHyRM|jtSl|`Qi815XPFlI)FtOIE%d3^esuon zw9o|4KAjdic{(jLTI17cp-HM`j+5`+w9wkPhx-Conmimz3(bX_3fIiJgsnmJBKLzG zmrM(-Ko--IIMAXRA%=+`EvcFo`aCqIkQRE4i=w?EgR}Y8l(o`RK3MWJ?VDRkT4?Rh ztfqwykz>WCT1!VQ5V31?*z?A2#HG_h%R91oT4>xht4Ry(>Lfa&3ExX3hn^TqaN5Yt z#2{FXL?4`m`J(4-+o>N$pqM%BkQ>SiP3m9e;IHR`zL&d7R~rAK5s;~QGG=O4lIH2H zNwVv)&wc0%-~7t)!;dC+;)H4Kxq^F-e)w%~9lvw$4Mc@8jFt|0dM-%@U;D`Kykne< z$c%v}JoY9&|H!Yr$0 zy?5RdCA^+V#wveOBy2kAnZH9b%5@!@QL=6;dai3Ldai3Ldai3Ldai3Ldai5j))sC{ zC>ts|lvZ>ot=P2m*j5~~9OG7Wq*-yoP-#U^EY4XHB1PLk!0f+`>50R4WIFV3G0S0` zp6*&Z((BV%6CB5LHS_h7`L5eT)L&1l9p`cNs&79PnXm(=_ArgmsiB@ko)tV|#d*!I zGuPTA+7-x|zs()z>>O8w)+Aet3^6cqZ%>;bdqlu{ z5^Pc>+=3!-6|OK7_IWYxYV>&5f9&4Gf1Jo;_lRAcaanAVe zHs(yogdNZv(CS@XW=qDuo>$g;ChRo|5nVD9w!~8toh1_sSIVwi$%GwI;?iNAn-yI8 z+-Jf*pU}djcfNqWuo592*y=gYgni0Wv$X3mrZVwW79q`L#D*fHOM9@I*HCxsnXri^ z+r~|#?z5B$`#hrB@6ej5n6aD%vU(t{Nvp?qhoW^#2-##E9dqw5>^U?*+2@EUW-@!)dq4$aSDxdSs;pgkL#it+O#TCtB2z}68@U|UAyPl3ks_TZA5ef(mo5_a+Ts^TOxS#1 z-bgRu!(L}VBl*x*H0b;ABS(`Wxz zE{(yF^cBDfY$=)F<+bBbG!k3d(_ClK+%b-kRLFGodeZD(PIA{Aa)Sf9yBTosaD#>e>dK z|9`JYBY5(i=12LOX^JV|pt{W>zUT+xK`luqMpZTY#3>QmzT3X6h$Y2vJ1rey^nth% zLWKAP8_@d-l`!4boSz>ogl2 z!aGS`T;=n+)zW*tN+RVNAN-IJcLpe4NlsMYHP64s!A>By$frZVqj~3zb&miT*t;~j zkt}lK+Wkw4E37A1C%r7Qo80%V_X>A>=ggYNWYyDPOxffqvyf0M$x@xj%)ly?B74 z2u&N4g;73eTh1ElJo#|J1(DxPSrVXM?F&m8uwrw`IP}kf4b*HixrAELw#H2jDC*z( zk=98M1gQ&0MH1daLZ1`O67NnzcgkIVnd0F*+TP+}^o8v-0R<4t5zXow`6lDi?DKS+ zgP~v~CoY64fYFWuOBVXi;ls(L2~z@1%z@Y;3~Iw*V&UX@V*3VDh3$nMix#o{24S8K zp+ShC*}RyB&5OLez39grRVuQaTCQwftRyYPAed<%CFiVwE|s7F%>eDKRL~#+be6;P zHZN`0?peDxx3lqMUP4~o?IrS;!v3}UvFV*jtrzA}-_AJkXwJ4hf|=|36X1NR*W@E< z=pr*b?$1osMW&Rx2)on9!rTsAo?&z=U1N%fZqycF&1FQslgciI!0fbTCRUL2YWp6w z0Y|lO^br)wozwW~b_x?4tCLN3t^SInubmkqDRinVokkr@-0maB8x{03m%UJJ$<40pw?6M zek+r7cuk@0yVBaCLQNlBQLPVn?j?wTv}iel#vY6*dP{k+VHzGAbCI>sZJU)nvlG+d z7$a&M)HkxJmm{H_Q!UJ`UR@%nL?Y4O7awDu`vM2W$Hfg^t8uq|7Z97VK1==!LWCGC zam+@oUi^wnoDEq#ZeCPkMOkzEkN49pe0e_cVdq1dt}|mCd#QmpQeeYK zv!9uUEQwURx^lXyt!~5-O1HCLv;M2gFWzYNsW9hHHtI&FmkrV$qiU76sg@2jwo8Xs zJA1lcG~&{B9Q&d+QmG|69f^xCLj{4U71fehtt2cy!xm5PTKn&(8jafo1u{~{#UF#ev8xA6Vq<~ZR=DYnL4o=7$9&V9 zPhXga#Dm^ALjgO02OPQM$UF-vsO|wD8R(fM&B2!*(&za92R4Z>sPZ+Xe1G+P4y&Um|dQIM86=~kLYwk7uiCy`=d%<*5o}orQaj(+kr+MQC zxR(wdM*yC9@pS5B$yaD4NL@NcKI5eUx1g@OHEDqlgt@%IrG+E_w3B4ZHs)n~Eyb6U zH=bxi3dZy#$u(Xzn7D=Or4c-%w|*!D-`bWuld zi~1G$j2nr_Q+HqO6Vae_c554ugbU+duih!nZ7|#uKF3F0sJVx%PiglL(83da8~YDqo@k%q-=n~M-z`b`%hnuQ3_4C||8sG`XFD(bI`5OW1s zIj~pOS@oxK&U~hpD%ty4M_Bx|C{bb^x!y|%6AV;XZ>~}sC6J|PFz2E{-h&R1Vk8`E zpJR(b27}l2HKnF{DNU`gBQ~K}G^-sDLC_#=1&+e#YQN-dIhI>3NuuOtJw1Y=AQ0$_ z4g6)VnWP%N+LWV>tBOst*`Peu>t`BiBQn_&$qrWu};k!@7x zh8O_3^2Kgc+P>2y97=OLB}b_jEu)n?IrI+jkXLipvncJ6dVr>FWsa#>v zd$^HK#<9q2Lm})YzHLF>eLrK@r+qO1UFErLuD6HQkxAO^@izass%fpOPDFo}ajud-iX_~&JxTW=>)~Yo(u5UNd zpLjGYJ=eR%rD^tE+VGKV(QPrhHpn)*vpP7ZGRRYm zoDFgtqF&)3BcxXdi}Z-KLB>X+LAFyPRy4>!Fd=S|7nFF&cS5Ys59eoSWhBb5LvGn z`I4;|12FY+wr2{d7@E!eiAWSW4rFX)GhZgslG|`_Q;jIbwaRevsIM|Bnfa#QecVU^ zm^O;5`GUmDn{VM{@t@xdnZ)70Eb@b6D;WMl#rdg5HijGFV(|=&CItZ?3^cN9>wk?P zqTE{e1u(hIH02p^6|E%0gRiX!-y`^vba-Dy!pdcfbRs*I))YR3Qq>d)!JIp0T@_y* zA8F2Ia*Vmu0h(*(TrG@aw#I5fN^Bv}M@HLm|4Aq}Wdew_Fi7i`pfI7OG?Ok=VZX<# zR$S>A752-=)SHknwZez7s#gpa-)d#!S;rdPe7Wf}z zC~%;uH8@WK(m3p%kA7T3YCl|_qv}2z66%GcJH93YV$DmU1Bb~1D-7ZcrY~FE(b8-& z1gW;A3eKkW$u2rD1vO3jYkx{hJ{k046iFXra5JA2*u@c2_RJ7}v);5JzloCRQFa6I z?0SEMc&0vKR%P3wTX~8&T^+#T=5P+*Fm+f7Lp;rx`rrL;-ut=i+8$yu$iV}tgcQ1O zr(|1dnwLqZlTSHAIErvMwhOT`{4DT}LVf+F!G96eHyR2brRCkq;KC6Ym_mbr3}%ie zjA>+fLhKP0{xONObpLBBvjhE!`e192_DmO)KTQ*1d_VxG`jv2H8gqeHV zW3So}HD8n00|M zch7J@4(yLx<`Is}$8VV@xPHq#!1Y^ZgRkGh@oS2|d4jzye7(cBaPJP^D%z3@*jcRgMT&NPu@RAKBt#Dx2L$mqUJ(`CS1>?!S`~-ERhY6I-~&k5FeYi#;KJ#6L4T z^qGw=XcnI_9g)uy2OmCz)@6Rt+UYF;S^$%)r#Qf6ggv7Do-A!fGvFvh0oO>O-%y41 zhcB36B*jc(f4dKfN2xoPCZ@wD!iQ=8l7>yeH3JaO5ph%Qnc9TX3)th|o0e9Zh&6d_ z9BUY3l(E%KG1c!4(%zUxS6>ZDZSAWd%1&VC$EC>yu9(}`C6(ho7)G1Ezbwwq3UPfb zqUot5uJ94z1t-TjC$3|SEGz9>qsgnBlQw>%iH+Trttr_W$HpfZpA-rx+#p9P`-2G| zz@(C*O%fGABxH&bNlEvZf0Pv6s953VuG{~ELXo64%EE_w2$c<4_(IEWXR*M9RB^=i zuA)3w@ve@O&_rsYJzE;q;_b#eSMX^xzs7xlFQXzIaS*UruL1<#EKF1}w>-|mmf1Tk z>t|6TXJ(kXyo+NzY=ReVq=(a_95BAmj_R4XqndaFw~M7)lVsPaM?UnG*Z%Bh-~ILE zP7FV-J-YYBzxyjTKY#Y!UuMs_zjrKFO zP}yj0MmMYajZb|1y`O&QVSOvNTH!k=_n78vqqVrHF&{b%UrO8W&MLk0e$vPPXU|N# z!M5dee2o3e+Pr*P9n?PKOwn$&x4&lSX&8FGdg9N&^~L*+zb|YFmr>A;d$kw7`iVbz z%bOql%zM6(aC$ot+B)ZzuCPuCUtjp#2i`jI6X3M5M?W9?gnqt)6P$%mVeZRc`u5v@ z^G_ds|JUvHCa-7h!I)mRfqCTP-}=q3eOh0pjWL7fZ~yvDZ+ZW_j=$HS!4xdO z7{s5pelUgBCo))v^#4Le7So*fOdbp=(98_TOFHn1S)N;>tQt0NJ?P-73?^ zc-^pJe^5K={K>*@eB`G;I*)Ph&7jIH_l7jtOB*hZ0X>C&B|@R{C&^g!vG32lq%Y>SCSZ3~L=tBYP_jka42q%0tXM77U3!xD^jE zSAak%egGS(OMd%PdpG zH|N6BISi)j08ocdPYXt^EiDx*>l9_!$r@DWj&IrRDKar!w;cP}A3UHqpWUo+fz<+OAywtm5;IpE%7*YqH zSbG_^9PMU)PgEpcNe5Epv3YNM(T= zfb&^!baRwX%F)d0haKHD)R5F(J`!T_`*v^>rQLKR29z;?w(&F!p!Uu1IlG;F0(QX7_C36QsG94(HFAvX?1rw$-+j#L?3s-l>_?kOj0;Ty6^_K5 z3y#Fie!-oj&>_4!PI?Ry>k>m3+>8D8o<0W^Y#C++K!)Q;RY^4xPir|}e9jm54SJ!* z-o%_e|097J0d9P7k`TV}L1i_m9zxF{3ewAN^6%^}j-b~K&$Pp0 z!UqTgzB>6)x$hY=ua8Ua*8c3SX3jqyKmVidyo8tQ-1zRNj6FcC%~}>4P2Pd(aO{EC z(TuRJU&V#4i^b7(u^QF0Rp+7Lo-gs>IdIwtpqUAu=9RU~GT$J*3PFs@zoJYrRwV6c zEP8?zyX*N^tT;AM$`>n+Tf!JCP7J;o?|On1yE{4fdb~SjDOT)m+mg#zv4hVyG&Vg@ zR+$XqnbIn40P5}30aQIJW|pwViVh$W1+Rb_dqo{#!Gfx-Uk9WVrJ4OnyiR5x3Jp9K zoAqq0cGt79TG6txnj{&&kXiRR<>S(%VGUWaX_<7?0sHAWYOFYJ;7Xp>sff%%Hmntd zfz#?o^%KGiYKIS-*3_FMoN=_1&3cGmL7i{|m^f3j#^~i5r|ojY%aR)U(INnams{}2 zl7~R9Avm>}uP;v4rR0_wKF^dAc_Getw62}_6?lP5LUjN=m}2l7^GA@w1~cKT)lmSW zc%D8y1-TbFUUo+z>!yAHPh&BRzsQE_2&9vy2m{Z}y=Sm~Z9*-Eg4WomjxEv8!`{z$ zn)RrDBC(u#Kk3Kfxk4^B$JM z+3wzEprgf`IJG-S6$?SEkDx4A28-txxw+T1tY(}uqzqRU&^}BGp^qQ*5saq-kLaVc z(X-G$69UySruJ$_P7rA1HW(~JJR!s{jSm(*$aTRq_!s<&jWYPSKspXWuyl|P!UqzI z>lg~YC{O&;HktMwlV_wrnG<P~cEZwZ_jlMo-Ht*)_DhVq4yBOA;>|8+7H16(_P1 ziP0jmF;XW7nku-$YbEWDV%H+5?Gno&Y#kg_h~=D)T4ceB($ZGtnUTa*o);?Bj9SRJHR2-d z`Ukl`G4i0S`M5{2{RgSpzM@3p#?T>I)QoH2_`_d+$EWT){_bxkGOmrgaqZDJ{lfd- z_1Evm{1>xKx~)#uyRU!lw_}z`Gt*(^qnoK7RqTBIhHgYlUt%Uf#6Yih?9OkD{{$Oh zsPVOj-}Hs?pWxU_lhLRFam)FEOGGBTN7B*(v@i}Fd$n{oPRUMk?Mvw*s;S$Ir5caEUfxadAeW1*G<5u}Aq-2txTWvp%J znALzV?WxUM-Cxzg=Iw9RqPn%Cs^JJA>X^~)aHS@6`Yg4igxBT-O zbZybc`8?f#zDNJEK?9Ey}CV}=|nmX|{Nq8JRuTCH&0woMz`*0qrj zp^`I|Hiu1*YThXWVsoEwqq2AF^@k7%JsK@94w)6BpVKjJli|q`D-LDs-oMn{I^ayWjDy-&h z#4313m#x!JrvDLZ#Mr$!fdtUDL`}l**&8Hpn!$rhZdrJ+VT9hy6NN9wbbR~1kFMZ# z$SQP4uWPSCe0?P!H;-88(0ukX2(Eaay|2r{&RjAC2rChdD|k<}3!ww|QPP70X)*B} zBXSR9gW>UWhlQB4sNDkH#q4m$cNFqowx|kMCv4aeL>54A?`Wy2F!GN0XWIQkV|VaC zm>+ypY%uLUXg6;vvlWycXMM*!y$)pIJU;NV=2iKcNwbZ-I6_9AQCj?4s9B#c{GZ1Z zS5gwZO8Pnur;_q*pTNv{_zH_ZsnQ-d#+yodEr@MF7t|pyNv|O`8~Bq}*`f%m0xYqr z#8?%MEnLqquL_IO8fc4{>}+Zk_7h|wMHz=jG&A~9=p~7JFn-<7$t|BN-}N z&;e}_Eg5WtfttWp?v+~8Zm(*%j#rPbT)}Xys_}iRq=i*Ae)Oqq{LoX``0=N*@sm&G zfF6A+8$bC}qUG_YwDG4BEf1^`@;gTg<|dSDOQs5l$|f4w#`3TwZ-|Z8z6uYF$J!!~ zx8Tq;5<&vRm7jID}%uvhK4ldO)5tC5a2|*}K1tytX!*to}(S69rPKIk`_* z6)#OPb8}eHJq1>54!#mAjFlBUlf6}#7$^b0*&#E1JGNT^Q+^wRxMA)SD>es8mT$!| z^SW4Z+#EYroCqCF2JB{SkVX0iau~wY?;H&itbvo;*`N_#rEJ!_F=%85?YYI4V+Wl zg-r+?_sCHs6;Qm}7<@Grn|A(z)g80GSuuM0cx|F=A$t0_J308L*us<{%3f<5WLE41 z{~vXyL%RUk5s-?$(%1>ev;@+M4x|+wNGm#!R&*e(=s;T0fwZCnX~ju{!HN!~72Cm~ z2n-#AO!E5tj3AQ+p}!8Fj<47de5j4YeX0I`>Rm`nG-km|N}~;%(1rweGD>=%@0LAJ zjSmI^J|lJqc6`=YyFP2I=(EO( zZ6jVQc1kun9T+Ap%oyQVg*8SZR_xkrwBlOBx)s;iSg`#Z-8uG417*z8r=JyldBA`{ zJwLFbSfFgu4za90BdUGY@F98j};q(zvGoL!V^P+)gG7{H3|D!1Z{o_XX(DETDu<4U_}>Otmv%9iXP8kMIR&* zTiMr2#2a_YO3vOWxpmV9(<+RWD~1aI*?_!S1^*UN8tbYtwBl}!q5W;FTjSqCkhL&T zE(U>r4<`5$CJ}}=G2o2F`IoGi4gLpjThYH{MPK2qIA&A8isJ))$%;-kR`f4f(N_w^ zFgR^Z2RTJLXlKp0E?Kfr9`s?yL89}tcRj6_LS(M1?CLM;;bRzPGaj(>h0-GE53%E%O>j7%}g$P}ZD zOfkyH6r+qxG0MmkXF`-6#WjH!Q|y|Ukr^w9vt5g|O;DE=tmp@xSurqRV-POcI+KuG zyFT$4xyt{k8=xHcQqN_qu*_-_STU+|RPZ&^?v4l9iMvkucDLoLvk(-cVkx@$ z0AD!YkF!`aatmqu0DwMd~Zyd<= z6YND54kYE!&|Tv|#W+V}mT{nBaGlrLYp$PI(e)E6x_)9s*H02#cHm2l=883p|GxB0 zoQykqmE;ZpUGFh8xN;tl6D{5WLE^4fbj{R?Zt=FFTfD947H=z-7H|9M2lZOf59+m| zAJkh#zAws#{MuULDmgGOB@xclB*Jqg3>JgzqJxcLZ5@&^(C+#{y;gLjSyYEfa(Cy;M;mg?R_flpk4cMT$lKw8AD=o*9-U4yWqYY@t-_dvcxzDYr!OQ{460sDf|DOD%sL53 zR*bujY}Wa%Bl|=ltHoVM+znP2queN~sh*LwkBk-Fu_dVNr>huNbkMC>VKn`cklnq)=is8)22YQ=bpH?KNJwYw!pwV%#Wt>_%piq281=C(6p@{%|L`4o!9 zL~t1aU9c3`e27cR*`lPXd9DzL(qKn5q{<*`!&~FF1#OiRz$(=wDXVlkZ1(Jk+=tFo z~KZ1EI+f%N;)ZPOmdf9rkr}e=~lUM#pZbH}?Ur zilo(jU(puznnU$I=Jh6NlKALa)y7CT|HNNVyxGTqp#&7YfPi~Uq=YVqKoJlE_B9I0 z;BVezC`-G)r=25cz(w#!KGA-NlkV7QQ5>C7ALqST05r6lYXf~7XH0PO7QV{GHo0p@ z3q2t0F`B53RL{MYJtLmuMvTs^f+XeYcuZ*5rI`+2@!0MBdk0l*+?R|rc#A* z2-`syCxW8|aybnJqf0(HayUL)jPu}=wKu9@ROn_u$A>xH04lAvMz(s=TU9*ciCU|? z5mGA+LH>{ahRF9(-gv*!T!4Hu!GFWw5an?cRv@JC#xONM;dQ49Y7X`MOtTW-uf5-h zO!@`X{LU&6qpA6g{(?HHQ(B#x)i~5_R+`mQbG!X$CHrHYl5;=pSsrQ~Gpn;^IRRUA z$>y0FUFwOp;c?~B5M5g3Md|XO_o+jl3eKZ53@db5XEx5ihN`^NTVZ$t9(m5_k4EF~ z6qUsf9g9uJqL~haMP3fvpwA42B@}(Sq1h_01kD^A>NsrF(R{Q-%a%o|lrydS zZX?&BCjQ);5W9uR+#jpR2s6ZNK{TB)s#=QKHrh|3GsA30E~;8ajn~fkA?!5QXPY4- zBoylS;GEN7YADo`9!@>3oRcwxLaV$e3je{^1ThV!tD|tNj>4~%RW?_uFucu?oa^ge zA5Nje=bRG9oPjommcB*VR+TV8B-4;fLWw)e1*km6Iufz53`v{uCggmw{08fi8iqFGLCKc~HUGR(80H{vqO z^d>G4Q_J_p(%=|FIwxcE)4yZu;AGL8scLUZo};n(?J#^)v9Xz4zBiWa=G^sWzxSqH z^hUc%i?ONq=B=SORI%Q)mhX*aNH}-Bd5ia^RrJQKBo61G>`nK9&=sCrS09btU=-YD+)9QNkj-kX`CH-xw>)|-1mZ!8LiwPI}f z-i)2w-h9Y=GhOtCWo@zEygl?Lu6)hqdt*n{p1Vo&3GdBV(Ho?l#d>pZ=uMP7LzbN$ zZj~S)Em&+69B~dQ?azjLgDkgLZ{8Dn6UDu3`LVHm?B{N5J~`YQd=`uK=C?v`%&oz^ zNtf@9DZl5gH=ps|l7sM8?r?OaepFq5zf)(1k@j%P6jCPKaTQ%%UIb3QZl<>p`uo& zH$ff(vYIG=>1*~7`O%*o{NE(99i0E->1>eZQ?DHS!u)*mQ7xa?XS{T^4HGhE!Fdw!bwv1hNqd~1Ou)$^C&wX?6@JSYVTi0LVKpXdDt&mcfR)vQ_VLm1Dx*ZSP#syL6&_ zmpnMDc~`T}@_p6tZ~{EQ(z>tpi8hx#BFj7ka3Y19W!Ftl>&MB|PI{m6q?IEP1Ok}3 zLVy^=JwgOW#|XgX5V9D`PrpTs|EYF~ZB$|v66|ioHczO~i?k`fV8-SIIwJTOgpZYQ z9>Hk*Mo~p}t^P_5IEZ0Lb}}$=j}i@={KEZTkN3TcbJ>x!-{{`$Bo$u+Tg7WG8Wkp& z?rV*$PqjTomk=M5br~Yzy9aFtseOyIz1cwHWpd-k((W%S=}!5o#>{_Rr>zH~l#>wk z&g9dn|2mQGO#Vz|P;yXLo*njQ$uXY&Rwy5({OgqG7*5t4?L8fd%_jHb(iTTq$;rS< z_brwXD7=^M3HhSBdriEeUk{`i$Q63^71r#KgqU*>rX7szogAuit014t?#Ewun+gYo zeumeJFia|wRyiU86B9T(#0XkKqT)77*;MyUcYv;1Z8LgI4sO_EF;7#R5zX6%bUr&i zycFB`H=QpsV_t7#*YHo`gRyQKJ5jH;Huf7sd8w1l0U=Vr@niuf4Vb9$7G5|F-l8vT zZSbzpuu9&dBwxk5tK=;ts3=7l9ey#qaAWX5YAV*0T7Ax`T2PUu`7g+T2Dq&@)lUM0O*A$(Ucr}-?^4Ru|3d}agNSt_! z(P!hb_4Xq z8^2;V80&cBe%%myPZeoQ4=Vp>n5wt}l9>!afH&ktA-7km5*#t+LQtxOL;<7?tsGv9 zw>(3spLsxHQh5z6FIe7O%O~d(`%k6|xvz?6=I#n7P4Eeh$cR6Z!EEv4&5BkJYSIx> zTGrI0Z6ov}W{p&*EgI}XP0G2+6xPha@pq4a64|f~TAf$18`-t>ERMcHD%5EsCOd}3 zn4KNNV#KuFk+<-Fdc^8H?D?>oh z@ViDn{H{?CziY(9?;7p!yC5C-&K4ieUu&deabQZ%kceVCYd)7P=fXAjw)Y*GC&Fbd z?%$Dqx%c^V5TgvHBdh$rSCQ)yL%`dN58~Z}EJnNTO4IiMZ)xeeGZG8JP{kceK!v+Q zz0J|ah>trAikJc0Wt&wuUob&A;mX&!WU$KEciO#Qj?6i8?|;FuI0)U$$4t3IW;~xG1V}WP$F~C)+E{W^-uiCw?6f!5C66wf#>HH{_P`gdi|&0 z^_dSm=I0f*Ep_)l{llBy@<(6$^!?#H!Abg+DV1z`S%TH*kyNMfxmn`N$xpn39QBQt zPh$D+h`nG^o3Ocl<-Yz{i^GU#%P__!qzPj&HvHvk#t1?gZM_p36Z%_(+2t zuI1T+g6t7a9{kOB{n7(qfy#bV;TIlx+fV&b%wMh~Fsk_Zhd=PGKYZ7L-_?!!FZ;nI)S{D z{a4ALsfSWx>koZEuiz_C?|a9+G1bRxp#(`@zkWJr$Vp$Jc0s@ru562*Qb-@EzvLwP z*bvx44E8h6_~WDe<-fl3gCwI*kq-L}nM}XPypZsyl)Tl7-~5X=zC&5-Rp%dL9i0(u z$=)&u0`rm2z2P07zV8>`|5)-P5D4;68fC9)4KsfKTM~@?brQ<_xQ1;&Nu=i z)CT7m`W5<9$%T_tL>o>McyT-q#1cg95*ZPzq3< zu)cQRWKgDVe_7bT5pSTid;>C(jDX-k2sGkRA@BwW_B);;1hRXKXyC)%z|@Ea4%Qox zAtdKWg6d%Zl}FMF{^8FbuD>m_$cWD$jU=+Lfg|+>WF{HWz^6))d>Ddb^#)}77}3Bl zdIOyiT{uy1Ko*h_4g95dL9)~^1gGi^$Otl`f#ZueFn_nvdK^PpT}CwU*~J^!UvI#4 zEcwZ-%3bhtt#SQch`c&Z!hn%Q%#s!uoO|Bw(Sj*j! z$QX(+;Xv@m?7?Wpa7QJqw_=|N%9<&{#!UF8H=|>iWSTf^jrK`WjBkps>u?q989GG- z6Ncu4n=tlUJwH6f_EYHW4mIF>>`EP3!RuG?x}DNcbl4LoE4W&?vWBr(QdP(!=!6nG zBXqfx@MlT)MwxOh*>|Bhe0!HjjoFA2us&H{w^AH!uwp!uhg~qXh!Fsf^-mGe{ju^4Vp%Sh%ROJ}hh*?K{&<=*OAy)W zinXIYE}!?1lKKL>;&G{?|IMQo(N!(?kB)-oTrKy@Kx*StU9KvNU4{P`p~fKxkwu(+ z15z=nF8I3m&xy!?3hPn8>8U07&u1$9=R|2AU5fwQXr$}hjtCMXQE{J_*nQ_qS7z7i z{*~Da?WXe@F)PGiKxHS46KG>_Fz_dFHQQ7pK4RR)`PvCp_1O|xna>v7OU~nH)#nL2 zgM6ZJ$GI92B#W^%O~M*m)0}a(#ugb1p<#M(+L*#<#McUuf|G=tuW8YNueHPck;n(M zZr?=aTaE~FgP;^pD-uIt{5t(bs4!;LC)P;mG@OfabT5)?JN)>Q>W|u!KdL_YZ1u+{ zt3PV*)PDSFwc4LmfBa?jN9~=(e|+LJ-uc7oJD;iksD0;es!#r+`lD9uc=gF&SAW#1 zE&k)7>N}sR{#g8-hgVkZ&#Tq`xccL-sy{wn{c)`NV{r&-Pd;t$Ore-}-}_S{quk6W zzE;>VFRU6BS5UisDlI)T+kd>DZkgv#p*PD7T#{5cXkA57En+pT5!m_kqRiqY{# z<67>BC2hV1rB#dZ7*E0;x(FjU)1BG=czD+BjWyUn+x?KiJjStwdwSZpL^#N}0`I(j zr)aCocD35kh7F6;%RNlbVG3VPsk-@=!sphWPwDB!HlBl0a>xX>6xv;>MF7C=hxOLi zxx5b>c9r^wE30?AG$2n}V3&hCLrp)sPUBfo0Fw3{OF#g^O61!jRb7gQK{)oE2O+5J ztH;7s3`4|NKN}n~snTZFAbVv$(aG|l?NLKzZa>3OfS-G7dS2UhDrc{0a?NgUu#ql% zjcgd{?Gxdi?x(N$p*Yf~80yqXz56+_15s_4?@qk3*BB)7Ul<9HspiQi*Sr{tZ44gQ z@qa2(wF5aa>)B=JbdTi|0DCQasR8M?da{@6Oz-a9YM&d@#ai7r>Q{eK6E)K|M`H(% zD$11btCPAS5j=vLB8yaFt>}!>v3!#G0}<*o?Oj=~gUha?U;XLcj6=Kh|9Mkfs=cJ)q$SyPr<|(VcF~S_SJoh%_=hNMdy>7m?cLCuJ zyBdIDP0XpY{q^_l%3n)^TlQR$C42j`z4K(wZVqCM<}(AZnMO9-$(xAzoHEA;VT zpeQxi8|dI|8tTvTW3x5Ydxn}hlI-F0Giqce^k7rC>dfr5MGM;un#}>4Id9^bYT_Z9 zSevg=6Ki;7rg-HVdu3C2t!~c18S2Kb@mux%+(}?%hRz`0j6$pwrhJg$&Q4=6JlEHeW`8xhFiz~yw-Lb&>Z_^9J`Fy0`#mKKO z`U_PFX(UqU5$`MF1x;%i2fAq3icoIiBjuJ8$5RGQW}SrBf~$+b z*|g!TW4P!BoLrE1?(J>ZHNnS0M!pRMUIGH!05DA#!Rj;*+CGU!O|jmk$|kIVwn;&0 z8=VbU)p5;{xx~3OvXSpm6Ptxp4T4b_I9tarh;4(D{slH`;UhF!&YB&oAd91J6hjz zYgvpZFvq?a(D?!kCS+9tHBkY$#c6MS*@`yxPgoQ`4J>7YU5J(QhbrZBLUr=Xf zLyOK6_sRV46M zFTxk{Ouu8YiM00| zNq!$od(YDKaLNdBy^-kdR73J8n%Z9C1aLnyXgjkfB+}tsex2^&%HF5z#Fg3Oy3dJ! zkLk+qQ<;lzbOhlh@l&*W-4KRI?uS5>>%z4QW$@k25p;sk`dynE$rV|?S1gRjJ1p*> z1RExn0RQ&>x>#u&>z`G+wrKo<3grt9$uhijO@Bjt=_=Tpz4Vgdmap$$5G%b9b|}gX zU6Gh{TW`#|LO!7WSgdyg%Px1@;j8d^k-PWbUoyoR2zK|^4m!60ClYa0%w@rR`(M5N zuaXx#pf~k;)&~3K@a9fYTMYyWya{zQQT$v2pOOT=RxuSOfj=&g%w9s`uM z^q)4Gc@Ju7^(2Q+qDpI;Pv?`mm?o-WKWr&u(!t@eJ>55m=4^h_+SH!@jFa#T(HKs` zGlSa#Swj*cVMKla;2WagDdc*niRGRt-_CpVvGK~Nu==+w6y(z6)?(l=&l{5IzJ~qg za_nwFkdr_QRs7iA-c}pCd(z(JqR;!sde7zsk;k-$R4Tlaevc02qz# zMh)GdYc69aKuLWiN>In=vb#XZDsVvS1rD^C1r%UtlFFB?4gr?xGp&#soA*{oX|0v_ z{J`j*Lj-f#-K*<)v(odE_`)>%IGyuMB3%07If;Df3-6@tSGJ%E zC$S!f3=))5U1R~-gwkd{EeU%%-;qxlhdLjuaMX2-X*Y~qQRU)S1lIrR{uPlI-L|*4 zr45hX0r1a@-0ITgjQX~`gG%3==Najrt`vAF4k?R9R zJ}+NirAT4sNELZJ_GxUK>_IJOhmf~mIxmJi zn~j=ZaXM;#X+9$0j`uFs{5mn-Td&zr!J1Lwv#Rq&`z|#Sk5m^GRkNyfLGDBlt3Mee zjiamMw36(Wr0YVmTeGaFQKjKhvO61#&61PX{tSmG-A&-AAd5SFI zp73ODXZE21T~U^g>Dt+u9n}@p`eV9Y zxidQ?xtjI+9$mNY%zjx{bo>LlViz#E8#U)H{Z6;CHtoy~TbXqWI37^>iYv2E>bhlT zc3juXaZKp?{GHiJT~Y5(>3Z?b>@i(;T%DZImF^#)jgMKGjkL7=U?|U1J{s!mqt3B# zpY<_&BHY)yxmW$zwllj+SHz@!x?Vy%x?&J`K-WuwSyvn<@6`2sc4qhJia2#pSNfIg z%sKd37P4pRc36O=%H9}YRBaJ=k);K)4TVOMlNZTnEI zlY2q%3Hac}cq!)-qXm;m@7uexp(%FpUfmyjR{!q_kDti)=EO^fPY@9$h_j<~ zFa4Fk2xN~#TgGLedX4R4d|dZ6woh1jD;AGFb}KL;3J${CRuD&?3XKP&_c-O_lrP0< zf)<|UM6Q2c&#6H)PHZK{`~mT_?ZWt@2u3{yG;GP)PGl{{=Q}E@1y=ZD)sMLrv6>j zzpqk%|1$OWQ~&Li`gbo=|8DC4R;B*YfKN!{p)vSK8qBXwEf0^$2@XNdWyi;QO{uye z+(W!;57S))*PjgG9#s2e*&a;6I9$b~JGhGgH~&Hi2%J{iH}|gAWq0p;b-Af`jV{;p z{wH0o>iy5U?C5=;F57!X#oKxV{hI6T(q&8U-|3R~UZ~6F-ivhE*t<@b+1~f-((PR@ zUe)St(Is(#_;}X)0X_U=*84$Sk7d0V>-tdEdx@?`v)&Ks`mwC{zvy}->;15<4`#g^ zbbTP}{fMsjXT6u|dN}L-dtKl8|FiclaCRMco#%PnS9jlTUD>iF#Y%GSjRLJf8jGyr z*2oUir(-)x;$S5>K_Mn6U_i8-AlphJ631@GQdWRqL>{1khY^rji;V%X3E*MGj%H&( zL(HyV4C?_0%*Fu^D}pvL;9ZhZ7tyZ%*`bcyhT zk0#wW>iMyx`zAe)B;9Y<^S-3JUC+Zw_sx2KIO%?eo`;g|Tl74bbZ^!Z_x5+{iF^C4 zdM+g0@6z*`xVuBor{nJ1^hD|WZavS$-S5%!blkl~Pn6K_)$@tC`+a&o9(TWA&&T5K zPCXxuyFZ{O`a2v}TlG8{ci*n(Lvi;VdY*{8KPWzbJnrt+?+4@VZF+t( z?%uBFvAD~|D#jmo_vm>v?!HsckHy`0>3JmXzFW`x;_hBO569gf((}V{_dR+Zio5UC z^I+V)Q_ll&_kDWqi@SH}xe#~lw&6Rw3$cFh?cQV0J>B=)b9eV%d*0gJXV0Bo1mTA0 z&63A*{_nTko4Oyc=Qar|j{R~{nv?JK>sov`JZ53m%hTi? z0{cSRepgcg=mzI78UcxMC}&En*kOFmDzWrR%i^qw4~YzWQxf`pb;%91CF)QQ{vP~* zlIU$XKMF|R%03Vy>w9CJKiAjl>yr~Kewe&}T2dO4L;{#7M_(}Uj{Z=3u&96*p=65Y ztj5^88Z^;s&rQ9at%SAwPa|7*%d*;)0I#XX2C}bIZr<5*yB2IWM0d)<(IjA&PbYi2 zoAub;WzkTV3#`;*XO}fNk@w5>*rDZHk@qTnzMbG4e%;i4sUF+9T|I7KmnH9XK?T2N zyHQqqIUL{{^y?bddU$MRiHGiPWG$Cpn^=b9v601pzFV&qWwC{E70Fl)=hs>$m`BEf zE19dh>-1|4o-rP)Kn*fGEb{Sd1*=9pI{4kmY_YP=ueQ8;D5k6+8QMHOfshs0fOe0` zMe*ui?QmG|)&7@wfL_BLiQOJ`BsL?{nekxpvTViuy_&;l9dS6N(1byZCJWI}U^He{ zfxTc~yZ!BCh(<12g>O=Yjw&xo4;kpB0x1qKRt_c9PsS<(pd9OjsMoTxNk7(!>n7K0 zteB4?Sd1RU8R4WUIlyxKSY;i{RflC28~``R2Gs$%uHpf;ugNaSTYIw3?HCv+n+tod z=S)C@A2gX={<_JVvdh^Q49-_-UfsS)FkzSOg#y`n;dzc`?ZRO~oX4|<$(4gJ8Ou7D z_*$sGtGukS0zB0NFa%Sz%LExLSb$xjIjTw&N0&l-j^I%y8-hShClh!7- zrMRw#!18Qe6)Tp56$+j!YcijjWo64fR^U_IsNQ3H3oDq|71_U_7FevNv!w&@Y&utE zYea~4=HnV>6xLR$`3e`vlD#Ec7&Ro%rStO zl1J8!Ut_nQ#n{1P>#?~(im|V;UKbRxhH3|UNKKZNt?^^O;=0Li*4VMkHFga56DU(+XNg^yyZ$}VE+2m^{i5%qMF@p_j@fV84*7`-k;+JV0&yuoPXE+CC10``r zPubg`>}5lgy-oPSM)nJZiS^>5z+}@!fXU0x36o3uU_waOWM>E_%LNnm>|ZEMtQQvr zCRYu>Bzy7w0pTxm16k!n0_VF<67e@65LRd)94w%LOz>@0*iL2>foLIOb1_*~LAaCG zO4vBzagD|gRKr6$5R%hQ} zJw^YTyezw1VVwnYy}W(7#>6=Y7dj^E!9^Vt5nzjr=>>yhLaCLJ`h- za8VGBxU@dTR6)2GhOw9qvIc~^VhG{ZOgLi7F84fp^>ve*+pp2^(D^c@hIqP72t0ia zEyL3@i4ZHk%6Z=l7H__%Ezw#~d8*3dlUdN)*gQ(T8AAL<#{#Vp;fRQeY_gw)j)jF3 zKsQb6V8!~P^|gY;#x2oCt3C;2=R2Ww>m+-r)5Ts2@^`Z&v_rdHRbyG%OFbmI*G(F5 z@xpJW=9jXmuxM+O8)-Ak!no=c1Iu4JcU(d4Xa^5p~Lx`9Sso~?9^tfY~djHUd7{ATpMWo0XUBQw`cUadxEw7+sJ zTge{8qLCXW3MFC^O1Fh2g*kIokHwaaDcEp_=1Db7ZHcz?5(~>UFP<`4sum@eLfa^K z3S7#@$lUFu4tUC~)Ujz`Si6;%z0B47GV0w-k#bm@)nm)bUgn3j`MSw#)Tzx^C3|?h zi~~&DuOdTuyt{op(DNupch81m@I33E>E4E7RRpJIRIRlHdsx5+l}_JZz=l9dR-U?q zoWowWuyQ(!-dVgQ;BuC?EORpIEzxG)4kGDR<()5*T5mzp8>kl~-PV4kANAJu+z@q7 z(gkQeL|J+&pB*aaMF}ffYpIrnEXDnKrl0|&rhc!dzuyq_SdaCPA1otsGZ!&)YqBdU zGq*6grg@-BivuxFSz9pBM6YT?vL=8E ze7pR*NmJe!gNlQ1ee-G4H~Bxf&!^jH0z;phC2-cyuMD4~a1M3WjKXSX10cmN1G$yf zU0|BLGy6^==5oL|XyH+dWmP%1HN~8XH`3wtZ5dyJ%i8t^lm}qqC;lwy$x4~=O1k>( zQpzk}rRX$5YlJvOW!_a!0b7;YZ=T1R z_k8l*2V!|HYFez7_hY}EG@7T}blLCMtnq3~^g;>5EzufU>3c1d6{8r+)Rj4l{f`S0 z?r{#1{ZhsP-`=ceop zj`?aM?6+k%ReJnwV!oD@y}|e6+pe3e4fW&Or1I3VH#n82ua}w{ul>A!@qS44d3|=H z>jwr$wk>;or61eW56jAK^!?yKuli6wwn-1FWj8uKXs91vCHmII`yuVtCGpZM_(ZLb5?A06-&2f1{z1U8`B&7Pr3ZHmGC4FNheM2Sv>W~&) zK|}IN;ba`fty}!MLoK_PGZgpuRR@+=_}RWy+uhs>#IlS<8ovNCNUM~+j`0)3BOvVEyNCy7GfH>C)Ot=F5RQ!>_RR@ zJwDw7*oVpd9-7@l^9wxp^4$Eartz34S@Bi(ixWny)9f2;{ZG;&KsF>}xeSezFzCzjV zEzyqwK4!f?AN~9wOFXvDR?FU3qWfL^=tuUu9Ij{Y3y5tQ&pBRmFrC@`Uva#?TjRV7 zY&bhwAJWXc?v>CcWRvP&F(x07!M1K z@41R~@&b(SF2T4rd-nhs_j)kCJAlzLo^vpo$@_e8*&ot4_hvtI4lo`O7~gHzr@67fP!>c{WPJ&&Mz0-qnkE*`}NnjZPj8lQxQL7hLND6-n1MUTOY9P~W@35Wt zpj~G$A?*=%qKpNOC|hnJ*yK-|D3ddoJlROTT$m->?3e~sF ze8SY{MB^k2E9e7defF~ujksapvbKRl6_H|7iIa6`usu9@Wh5A)r*}5mzl0(ulv( z-o34oF3g#M532c9o2iFS{(fO1Q{q_6Xn!z!yOYpwX9VxaelSb|a^@Y9&X$$E z-HWz&aBfJy^a1McAf{CVdpj|%I^(uaI!OgT79%<7{oBK+hDzMxknYySJA1t3DO`sg zh+$VYKR^t-95HC@HvaiQ6!zkICJNKm?&-)=yzqeo`Wb(2zTa* zn=WVg`^y>LnSKAj4Da;p?)$^cTE_D{vmBm#QQ4g|CheE|>ST96Akgp3e&8H4dr~v| z{Z|o}=d8VvTnfH0GJl9i99LApsQ+c(w>aB46=yrb+hJ}05#DBRmA3y@wEgd*XsPXg z*C=iOlpR%X1+%w$p7>p(oFm_L6;XIR5+= zKz=jD%O2h={%l#Is_0Ev3Ttz1b}T3fuR3(z`|T zFT2<>0_i*prZyx1cc{5>rYIHjr9!5^-QgA^2BhY_$_C4p|rVE6aW^codpj%1cnr{u-6zR8A(uMv<{`N}# zNG>~)pT1-+dHsO%LHgo>#L#Fd5WyRKTd{mfoj*4^w^I$hm|Rrf0~N0aPd z2a+6?0Y`Y7y+ShJ707^Zp=e2t-!e)D9I)GFtYG#E--T}(+4U6!dSxa3ib{Gmq#ZJ4*92e>i?oyK)HT@_$N#o4=&LDS z0{m)?)3UNHewDOuhi^E<8GIwnBH#44F zm0`zw-(o2po`18Yrb%%hJ8yNK{SB6q{(r5d){xSH?YcwG9e~XxhNRY8o=(?ZYbhQ0 zuA|`H(WyH6okMxZYg_7Nr0yh@=+r9tjbr-yqrZ6qQ0L<$A2tx?%P3Hr&zDust!8Jz zTCFNX%-*}ob9u|dTdjWew?vOL|Fpx2NveL-CE1M5kK+a&5}$Rs^iD`a2OQG;q@Cxg zdjJZgNhdhww{m>&m2?84AwVP2O_C8q!SNII62D_EaXaQBzguf!$>l$x<DaHH;jxpcK3iUxya$e7oqKEp2xNie#AKY`Hy=cUL5EfBGe`P zl*UK2AE)Fa!;ru4LVIu#ApgMg*!J<~vF(SS$F?7P9@{?sJhuJJ^Vs%5CyLLX$}a+q z-S<4U{lJB`{gW%>mZ>BgA{`9jl#{koPLL@&#KCLyC_(I1>?M*iq*ioOEpcaw9l+04 z$@yLkJM8L!Hr+QMwWet*j$3-lnngvU?9|2bvc}ZJWtYX_R&sZOfLri#cL2n0%}as8 zZpBHqf}O-VIoZAhn~a}!O8H{Nt%M!c8cNvl=KU4gndR3_UfS+VpgTVX*{muNsT^djF(?>5mp%jLvx%ht)dD6il`bqq8j2FGH*)MN#ZGuP z^9=yNubsWL8>h3p;v72zoL#gcLlpQFAk!ttUHsC582c+Gn9#HWvGNQ@L2S40&)%Xx znCV4bu0IMqaj6BHDZ0WDvLU%oWE2{A-TrXg*KZD2z1SVsF%W%iaE$52Z{uVAZ5+9{ zZD>k4Skj2k&+yTuwO|OhUiF8fsdBOSQ{vdX~f6McR2COcdjQVLlvjWV)($zViPM7B%MFI2d`eAJAdL6 z%aaz@6ZvbX&Xji>E~Y@AEnm*Z6+PsiZZy(~6FEV~yEqdi&Fk|VeEqe^zwI+-1d&0-FzYzLS?J^qZh%OhSyPi|3RiMAipHoe4{Rd+&?zS-a#`?O>9S%cX zXAOcQaf+@>qow9M=PO-zjSYAGFGh786@5h4E6{cY6aZg^_ZLH5*GtLQ%xf<0wz=pV zSevcy#ay5T507Y8Bf7R25%xiWgIq*01OS#+INK)_$7~+Lz)LGNaOK5N4J_PDC+p8v z1LqDDec(!sp&B-;hHK7N!?G|h07mmleIg*vaOy1BiGRtubbD`nE`9yvxMx8J{^N-S zij3c%CwrtfLS3|S3#KrS9o;(Bv~+TYodbpEz@}YrcU8kkIv;Rot^=4@WtvX5_tHGM ze$vuAx=rISskv0CjN2~qXe-vyK}R*>KDL@_Em1)Bfj`PJw$ib`vH^9buF}#<>sc#n z>O{kdtV#cf%>OZ09Y2{$_MO~y8)!{TYOE8wR!QIGM2A(p;mRJCx`zfLHhmv;VEVW( z;(3*ahji`{=!EGz9p4ROG#?tGt8{a?i!zHB>k`o%q3^8a4t39r{;_=9yls0J*OsFsA9s9? z2)M=|v$lzEJ3Iw^Ke{N>HpcG|GZ3AJij(p$MBZYih>LXA22Hp7B4 zoo7e9n6&P=8S4EuwRuyZvP#4B#zYN)!8Y@7eE1-ROJeb06l*UU!R{VzWUETvaF#|u z;&RBe%o+GKH8&C>=0`|fuY`{Q{aIp%V$U)GSmMBk|G-|*SW6wymJa-|v1$U0V6riG z$7ry!=huRMtV{k+wWLuhEnJJ5x))BtsCiN`WdvR=Wu&34dz^DD(Jv{TV638r^jcY6 z9K2W0HzJ$7LSls>CgbfT>fta@9m!nO`BCA!V3OtpVP-r)#+paQR1!FquaiF>7vX8q zT&nAdY^*Kkf2rp;I>a6mVi$Fxh}66Q4|R1^S5QKR>tbNrIOq4v@x698PBYUGQZ~UA zgJ6VkA2w%?-#8gF`mwo>e&R15)_<#SqV3AhHz2!fd7X#P9aGuB#ES> znqPyPHdBgbFfYTAhLqE_QBaFKf))@Wbc+!&kjvb7Y^L{_EDjx8rH+M} zwtmF98-o<>Kr5lNZCDBTZQ(g#60>CDlx|-y=S(S07$FZ%nY<&vjIKmmub8^5QTZ1) zl13wK)aoDw#cN%~7m;Z7aFH@Ux+0i+x{h;(wurAYUnDabE0F|u;!pQiXRnp$dgpRAY=_=#`VsYBtnZe6`rCRv3U^P8l8my^huE*m@oMz))$2 zlj^-wa@oYhkvOh-h^m9pR1(EP9RUaXn~7&=)a3_cSj2)(Zg(HKQqtE1(BSifG$VH^ zagx=R8=rQ}nlikr74P&bxJUWAUF?Yg;wy?DClYRU{;DEf1h7aK8JLRP%>PTn`X@ytM1#OAAEyt>;N(2T@XUnx@W-)M{fwI&_1S zuE9aFiI1(A?|eW4kC_y*81ZCWQN3y=MVU~l!-+61)tX7M6_f`zHq&jo7fa(<>)308 zaMUWF&MrlzHZByKpk0gy38&$t3X4DskRD{mJ}`x*D`ERT#0&MdVL-cYC@iv z7SirE7;Xc9hcpgjx-Bu?B#*OJ!E~M8&4A6qQI?Or)o*JjBEEHc%=E0GQclnW; z*64Q=H1mISTziDR56PR>s20HR`aty^gHJ_gtX3~}&1Zie&wIP>nU7j%%)b%N^yz@r zuMkW!=oI84gASCunb~D#wkNYM;mZYFz#NTK9D7-?*7l9q>Cg7vCwp z3bkw|xrLf&4)7dJ=7VL{D^QHt;4{Yh41( zKj@Qy^_WjW^+!p1*g`p^?=v_bjpsYRJq+imW}2pP5}~>!27G~|m&$k8P5I9j>gcJU zCDr`~S_i9PJeuiiU7xOTnQ$Iua=sRlkS{p%n-x9l^)^?6Dbd$LbEyAwnkMN?HU|>r zbX^SzG0Xj?CTySl}qiONi&z) zJCjbU;4NJ%zXK~z#l?TzO$h>t$j00X&Pl-C7lNr!RHu4w;^D?8vrU7hE%>x}GO5h* zX;RbhZowNQW!TtSY~FN0`Gf&QJVTkhaUKH>36`nWZ`2JCG;pz+S7nx>wT?^C1KD+S z5M4E_ot<-IyEG#)>4+1U=wp*R<#)poR%R9bWT=TqvDK4FbT2>Hk&tO zlm;0kC?J_YR}jFYLKdU->edod0ua?CE z_6~`ew>hS?=rS5l>7VvphMxavED|snxVSAE6$3f}&J2ynC~|m_Ky%i&NZCN+nq~73j_Ktt}YDh z?%Be?RE|w68M3!)$^dL>{QjvlO5$jOSDcLxZPSLmHg4=@0A4sCQ&eYA&GtZTGNG@Uf+@)_-$M{RJs>9+vYN#~EG@Ev}RC9?qK{5X&Ft-y6A#DXdufIhM6 zqk)|=PQTBwoIp<7G`#AnPjy6YeQb}JpfW5B(3(8@DbWan(&yXVl~$x~CM==?_JVH= zt|`LL4G}@eJ>VlZY|z0BP-EFPF#nVvH(Tf%7QE$tYsCal-HTb zsZ&f{8SqT;lx3GGWERDkk!@)>)hP+Y>dw%RXSD!W{|c5zg{>NKbp@*mFt$Hq4FMT$ z4Z&K{dRV_Kbk2G3vw{V1?9^5m?RxKi8>d=1lMGOtS>u=8qBf6(0RP#lsoZk4V@nm15Yq?FeIFd!5cef8Es@bBYE*slkQ&u6Gz5pY2s`e zZQ;m*1N6bl0Xoz3o&RDilUc=CC<#bP(XeaE1CtmgnTuK|s8HTQ`NeuX7Tj~J`6Mpe zr|0z0sg*ttook{a@KgRI+~Mh_=m$n&POXa_UYzpO>ewJOfKC+?#c1%Mf=*G!bBlgiv=@J z)*7UFLn{g9lpuxR4z3vKN93&N851GZ@hdpLWqnEQ^1c+_qJRrIU0nHgQ5TD-l&N@Z z+~Q{D1Hm}c3+c#dUM1R@6|EkN!P%vOW5FB31*kgKr@F*F0&<}8x+I%1-ay!baf9Ha zpZI9Au4U|+Jj!>zL|;d_m_n_Jvkr5s_}jOnjl-kY6u`nt_E`QeSusQi{5H!EXj=K_ z^n?om{W*?wce2SA!WG}7iW!Dq@*bN5%Zm?k@==^e>hQidUL_9$m67lw*fR1ji1$Q;1}_f&&Z zd3HOUeice-P3CDV>4fTVP}~lQLM!Rq;jnnEV^Y#`E&C0`kZ_04YdMmb58L!qexp=N z*A2gPowCEW(L~a}48sy=8OJn@rF-VTDUCQ2*Q$s)D{nd9%4u55x%Wf!3_EK2c!hGF?b15WkScKF zJ8Jm(Ugx8}e`oIgZo?U07pmWIT`fV)>wcBG;7lCrmvw$RORo4=NI5JIP>DoCu5G8V??r0Ld0;AEntc-gg_g9z)$O13W z927`Ej76l9j=1FJYuOUl8|SCw>bB4qry@dXG6`0I)mFHb>04TmS<)A&g7$26xOfyI zW-S=1E?Qg5G%*Kbeh%siC#cH@#h)M&nn)c8O+Z0$sR@gkYAXg+NLH(ED@H{As3P{m zbuIri;UbaFo^Iue9Dzh3vAjvx0G-RnZ_QZgVgL#Z(NTW_a4h28hBp7S5eE}c7pbYo zaEHMjuTqag%)nrey^L%KTe8+J7SLY&PQU)oP>3acjT~fK@3_J(8BhiQ4wl(klrbur zbYORXd8P8b)*Y(8RZ!Aog_7zNF-n@OP|_rsqNJhktCZ9Vlr-h4h;`7&FL2T&Gf@{M z2_|5FfQe|&%_BUxg8engBtHH5-ei@86x-w~8BqguRd|@rZgDWuB`1@PdK8O`wr<#g zlp-D~kixySQ9(4I6SJKdW}ts+&69_V`lc3yHv6K2eBn*I7ZyI#e52IVG{io&SOLxI z3Yr!!Hk@z?5r1QPDNy+8n0jN|^wv0`eHV@R3e9Rg$RPE^WzeLZrG3j@5O|RQNjQQN7)yYU~5T5X^1cJ>>|V>^abpIl$BtDbYdYX z!!fcVk_sr;+$WkIv`k%!r|79<7Kv@>P=^Hg1s`U{3mzE75lD<~B=)E8jQ#PAij!~H zW1&-3%n48eu#wFda12Oe5n7c3LK^Fp)q+C=_YvUo@Dj&{)IxBYw!MmR8~65N#unO8 zKOEc7(ut$vB^l_np47hyBktoQ29>%4fmEyN%WB#H1K)58Rm<&Xs7)0AV50Ufw!idW z{}j*E%HKLa)gBu!{(hv2Uw=&)gZ*DFVe5hK4)tPJUMsw+>V2@bkFsF z(e8<0tKFL((Yt|`@eZa z|2M1uAt1kw8;jZCrl#FI#6Q!JX;y4D9sm= ztK+j30v*#FAMe@9U_d#b3%62fU+|BxU4UqfS#1_ze~`V=h`ul|B83i1=aUX6)OYnf zK|L^N@7V`TJ7KguZprcMQ=QVc>QF8CjhtV^tDb$p$i-IREwE@<7qFH?Zy@YffT!+i z+;0W^hLLgrqKFj{=ZtdOTq@5bA98!NoL{NA2}g}r_JGtUwG6)>qPdQ02^wGBEQL@! z8&Nz{@wEjFAWe$eH;yR2QN=eE#RUceMYQJubO;`{moHvAG)hJx|E!sw*V0BF@7m_2H zK!IwV%_D`gLkbk04k=J30#k`m6ejd0IY(f;HSuMXQljL>LeSV)C1{LQ4hdnY1sO__ z8!_@#dZn!xs!Fc~2|B2jxMHfShFAsYg=46f34U4^;ft6Z6N%x@w0upX)-~koZRP9H z^YsaYR5Z?eR2@WKrPr}~ZJ}-ytAx5~QKtB?+H12AP?m=JOV0~9dn*QNqIcF&{%(nC z(u$HC#Y_W-N``Bx`pWu79s9WE#Kkoy)rE)>Xw{X7@^g{3?}+eESD(^;p+$eOf&>-E zZ$TB{&vS_fU_Rd297#BU2RLN_sdou z+8P>yi&3-LWStN?WUfJ6hQTt)F`Y5*_zaNn~rX&1z+;E{KxhUqtgyrhcCuZWJvpF-kNFTV9rTp;$&& z>99`^udzI}I(Z1*0-tvK^lDvQGEf;3<5Y+h)%eYO_!_10N3yG=HA62_DpZIETwhQS zi>mD8>ddb-pY6oK$~F@OUgEO-Km`x^ucJz0_n2;*4ZN1YQd*Y<{$)W-qE^62Sz4JMnYh5U&X0Rsm{_ z;pu3)>1fH?wCTW)FHt*=(87IXqIN<;s~tpbyCP~Ujxn6Ceiu|jiq;W!&s685ksl%S zRqGl06DDNBd9Au_#^I2p{F_bWe`+jaf?#d@_@QV<&H1mC5!R!oYC;=9<049=tv#e| zx#!)UQ+e~G9dMr0mWU`3dU{oIQ1x0T^MfHI#O9{4Av%I^*W}Rkj}fyRy5f7CQR3%9 zi7?KCY-E*}B9sl^GTGE}gaBUmhHAx98_GROH;5^2C_0od5WFb5Ye{q5$UjV=dSbjz= zdvIX58ZgEjFw8F`ILHAva+qb0)k@0U(#7}?V-?^(>RJhX6%5_vQGt_tGz!jWOkf`fBlk%h#(5W|pmK6L58LGL_#Kb2|{v!(fP>ACAFm{AsZZ zz-Pjru-yQ(aoTMQIH36?lsMps2B+`b>26jPg?;!nedtPcuLjeEb;fbNYzGkH#CLnN z+v5Ebya+Wcb?92(!)e?DZ*x5Tk37d8?pH#L!tjYVVjFG0nan2w_Svw!vj0&ewQyEYFo-%Z? zoiA`|{}&r~WcKd!kA38`jXQ29Ozjejui%~~_)m9*-=6Wu?$qUG=Z6g`Ch$?{tv1h3 ztc#+#FaFNYef*Dq>Cs>L`)EH6Hs+Q0=chmNq2KuBKlzQ%NBj3}V`>!cr*3@V&wlme zgm2WjMiHKu<8GC45?cDSYpGVWG%hybfi9{li~g1?I@MQ{!tKiD3=dhy?F27@)B&;Q zD@B*F!Gy`v?t24l_c93Sax8tD$WgT#n<`ljnB*F8z+oaB1x|vU^vk11{~-R4LCB6R z-dvS8t@MNNl`@EIB@}40QVd`j%zM$!5AvY?&Wptv`?v&}vO2;X^j1jLWb7zCDk(t7 z{Kxd{NUS<(fB^8)fKeB-9OYa$QUIaXX8k8`Wh*lhENXBU83|&6vP%jSyh04r7_PCd za_V4RW8t{_0%1PjP}9Q&E?RdZDwz~hDfmBt_o)|C10&dQN5KxgH&TKs?TU)*YxS!3ofwzF~?&Ej9prxJUn z?pK5Pkvwowvg#O4g{C_zhtn^^%O0|N1>Iqxkik)kz;!uJnU^uX#Z}=?x@oD9{^O*Z z37yNK!kl!2`)mb8t0&!1EEI67A7jNyH`nXNxQ4vVN5oYh$BA~SU%haI$ZYP=tlZ+g zQxo*nk7Eg4b^hihb=7&E7wxJ%x1y4wt8xhrb#=G8x~J$WXRkPrFQKc>_q?R8Ixloz zR|DAH2|~^bT^4Xt5>B+aeo}m<7{~&O?9f0Kj5FSVQIRIy_EPV`%kj-W74O3bi*RB3mxB1wi$xPcGY!r z0Sm(eZxrO5PdN;}o%-O`;sb+s_<(5a;ScuegFA~4(4kPnfRkV;P_0I7L+5iKR=9~K zL#my@3Jb*9HYi^ui&5#4MX>;h#S0woFtxRd=EJd_^*krQDCSWT$DG7m$xDKmYFwa{S`WNrH)7X>R|HA!AE{d zEU+U=%fJqEzv6mJlG!{i9I%?|9M_ve6K5P2G>a~5GT&1~-^|FF&f%S&uJ9=iL1S9b z2&-!oruoPPX5h<4l+RS&d(YS%>vR>y)`y$o6_>(7x)VadfOwLEEE1$C{~!D}BFh)4 z4IqkPT>rRObsHMjX7w|yx|K?imIZ8VESn5{vr*rKWfS)elwQG7<*jnKsJrkcFeCf$ z4Iki8rK?owngZRhQanpDliBS)MbA1CPbO_QtiV{b$~IJ;sr=U#)s=RmaDaQnoery$ zNC5mYv6_``UBVlZO*15$*)nmPWwzR~+)D9jHR96i!fk5l4F_%h6zBhH(iM{n7Cg3y}=XH*hcmehPU2Qrws9Sn4($Lzb^vg=_g|tZ=W!ul+~}-HJ@P zJBT#>a(eH+ZJ-eZq{E)5!dCFARbaI8#b5a<_(hy88CDLHIYKHK)_|hkTz>MtPtmtL z?fi(@B&xQ+#=(&#BGuTNtQJk(KXUoQgMXXj>Eu-9gJyNbLNEp4%EKTWK&$H&LjZ+t z?0`0X3*#F6wz_mN_^rMy*2~UReCop}dLf93M|yx@< zFzG|H#)wPzwGgdZXLZK44&d2ZSYjR`zsk3LGyaEZ-^Pu2?E?QIuhLjRRg&>=$8=#h zr(D|1OmGqh`M9`PHsWkYJ#yOY<+u`4;DaRX3PVVq1yeZ z;vDq_uGkkUrqiu}dn^CE)=M*2YlyjGbzF*zGO>FVKR{6#%%zb`C=uwwe;CPxWW`n( zL2AJUGM81{Xt@JUhE$^U0s3*K}K?u7BtMsT~8#Xpcz~l)6AYX9iP{6IZIj{3w*C9!j74x zVm<75b%a6a`!LF-tbmDVMy!7o48zvGXc43>xj0;Th`Lw-6R!;1I_xSdR=hq~>kH0I zs+8mC$NgP0w6*lr%nO5|=@-8M5V)KehHVL0O1>Q}*!aDGPUDE-K~SRtqP+t0MY5q`cdhaY0gE0mv|b z=13{uj~Z3o1;)ELPJF6AYP%07mW&6?Scu$#Jr;(e_HivfdH=Mw!QD3!V<19FAHPr0 zFPr-@OZPMdKpv|xvLRS4X%~gLQKHZ2F(H?V4QkjfcqJ-I*srB$XDmxbq;oM8BpGY+ z!;082Q-R!#t6B_xb&?dS;rPKw6X86#$_PM7u2n_D1LuJ?^mkP+RX|H@_>kISQ-hf+ zLRb{z8bVkMJ>2d`A)E_fG0m|BxuX&&AO(!HnF=C+@MY=drC{EnwBlj$SI08~?45%ZgEFE)hS-t6kZkF$TisVttJl-lS2#wB0 zSPzSc$O~5!uD!vUoQngcOKv%H@sUG39EG|V0Wwp75d)~kAz@Z~27Vf^cSN%oM zEcBv~0=r#;DB<_%*iM}%vFJD4T#fH45d>)h^Q%&Ff3NnQ`}6c&yYgu^p*HiI?M9(y zzC+K>&jvN0n`E^p*35UZ*@+I)bq}@k9P1XDq0DLL-zNYs-z1K+cvMwKgO=oY2-%R&v1!+ZoTn!hJKTH)1oE zZ!<|wjqRfOdX3HjdK{NUKlljyWTajBfjtxr0hibqoA?0Hh&BfyieCupU8=cNPc$v_ zUf{R1m?~E4jquy@Z7k#%b{ke|%`Tip2a0Lqpp=x(X*0#a=7gw3r+H<~iT|_4fo3#p z)W+s3;?s+UQIq-_)*Cf!`KI;TjxTGv2N<zhpi#xOVUhV_x z_5^G7+Nm-Y564f<4CC`raaP{7Cm6Y!M6bLU?jW-a>T6c*wE@UinX8wuARwnBWat5_ zfn8_>yQDYv60R}EYoWBfa?VmxxD0LDUTwCn84VSKsLl$ngkhAmg8_VtYRYj&4WczK zwX5T8{P^-XRGSP>Q>9w!i&dYP zp+OsC)+&Q}{q3^SN-nv2V7=jKXWvaVqFDDF8ps{nFDM||m8a^AjQJ|g7!e>^EWacI zoKBzr>tFqBgVC9c6)>f7aKs)5Ws=}KPg~Sn3pKMRqrk>$&D!o_yCjfhc=Np8`(Zn) zvlrnB6BOwJVW|q}R-{CK3+LJQr=Iwuk6Yhie!3GJrRcO2x2~PxZpy4!1k4y)%<(En z4*ms(`LbL87-9HhluZ>2nNQ;(xr zdg{bOL?_r$f2$ZKn+P%Qy_C?zBKA-l;(jQdyWwiH#;+T$c97Y%3JA1|U&9kGWo^Rv zr=v$3qe%r?A08h3!t%?hMk;(J$ajO9ksICD{s)geOQ=tK96Y$F(Q6)AvqVFAaA@1m zx*z={`;=eysaEP7j&?MWcBr@JK$B|cR_h%fHgD@SsJjN6P&ZO&LtBP2K@&yCIcBb5!wxM5mc^ zCkdm%1IAn1IC5otDAlx4Gk;YHPAGIYt!oGX+ScJBY}H|o8_-O13rl`qN;Njz2E%32 z`JhFL5E_^zgAp(5VV$TPJ}cX&-U8xM3}L-?{LUDMXf)l}ReZ5wE5E3!ApkhQd4i-z zZ5;=aA4*IWBOKHc1ImPy3{i&LcWQi&79%%#hEM2cefX(qrK5Z&a0#2pF|EZkhywjN z{axl36Dq+a0i5Wp44V`A^q7-ujQ$Cnz)2G~YH|=sDq?Z!q9wj(avC-{{0);s-P%@T z#2l)$F7{80u+VVOmim`CH6|5x#0K-O3mDIPc3PSpJ5QLFeu_+Qv>C7s zzx-+Sv|Sq4IYLv#BEmS_348{fRv9MMro8EWEO7=~DwA@O94ChO!3s4DIc zJAm%hVwb9%oGF`5G{5?$D&VYXZ!|j!BL^S5pWm9~4AUK2rCyV0C{vO%Iy%B?Fu`w4 z85XJ3P%GvFbeli@@W3LIGhjQ+EB?ws@b{%taQT9u!5Kog7_qZd^qfG@Y8M2pRfC{f zdQKqt)BXF1(zSACMGht4mPX7DH*tEI+Gg?aq{Rm+cxooH1x#xqy;BjySM%|3ys4gq468F=M6WECodg0 zoYTz-8H8TVd~b%XC8oH`U!bol4>r1C-4P1(#7hpu>gWdiKmSUcJrt@5_K?XbB!J_0 zsAeUXmB$9&2k-IvDS8=7b1dpB@Z97SCf#uFkyDmmQhkt9sdzX#eQ1?>x%6EMw%J2= z#F1cT{t0X9JgF(jDd<|qXa(N!HA_anFIVW-nMx#iR_}ahBvbjdet}aDNCT#e1o{My zWQ`pU=zO@c)9`m;j_7}0{F)CJ&za>L4VVuXr&^!Dk)v%;;M5C&gD~;}M~7H>ful7J zI%J(F3DI-_tt3}F!E1|pwEU6~(E&{Sy2jA}97jX5qci7dT!&Z{B-QwuC8?hni9}zk zTFXz3gvObElKNWE;Y#&cVjU_qIjuiRVefheaUJ#B%*v`09C2m&MyM38$#GdYh|!|O zy*_QrT6H{5kgII{k{Z7@^+XOK>ipZU3pmfxg(5#Tk{Z8OdDBO~4vh+KIBR>5Z+zZ4crslc!Do2{sh7cJN-|I!uEu*yG5-#q?xPn^^6 z&rA56!xQ&G@l)@KQ%vfUcgb58+g`xu!Wrl4mp9H@HgDWvTkn33-Ep5y6Ryj?%X|6R zj`WqZ5HCpY;3h4_2jG*jbLj{8!G-7@s>1UFuG}RUQLG`E%~ue^Xp0O7_X`^uyLv{z zo{Cc+)a6%Bt45^lq-f+Tw5!zosX|bBj;%UHszo8hi?3yBmg^T6$|8b|TCj+X!RK0^ zIMyn8{a1L3FM!tvi@l`hXUXKX)SYWxZ!e*FPH|-szN)x7pj$H*Xj1VZGIJJD;Q~;4 zwmZgV6A>F6_%2XPXq4Gvx)^)a1!#46(czW>tp+>`E@~*@#39^%iplD+BOd^83NJw> zxvpfWz2t{&NixmwzC!Cbt~JnqPi z^IBhXSWR|%2dDPA-g+DcDuY9|WfRI84qMf}&jk+q0B#0LXN;fHafU4*BUiO~Emj1M zPI=YPx`muw3ZRJt_pzb?TJBl{QDmz((J*wP0G-&N184`JBR;AX`(qC&Jn zxgpbSL~*Py{LL5OQk|dJC!sFRMl;$ZYpk6+U9=A+p^Q!#AUKwD4#I~zCNE;oe$cx1jCM^SWwu0YaZ#E~tLxdubQyGnlNKQJSut+^< zNN4b9hY9kZ8+H{w%C;X3(qca8s2!i+f<{&7Y~w5{7}IFSY_xXV0NZ~QGnyYvw9i5J zkIr{~-qs-$w+0eeUKa0r?Vg)2J^tPSbs3XEoADd3(o;t0FA5+WRSss*z@T^a~R7!L1 z&rK1uAxHYDK9E|+^^g5K#U_q?)AH0dhb4C<*fcR|{f2F zpT~IS>ji>ijrq=(6iY{e#?|p=9(CJqqXDhg?VBR+WE12)kt2&ZB6G|zkLGr=-QTj9 zCxIhgm+qizR&&fI`uY3`Q^{Q0)2`~wz6NejrV4WPb&m7B8)P>;$3bt9vsI4WddrGB zi{5{sW1ZBoPd2)3&BCz;2gkTCRt$f!qW_CY{{`p&*mQTN{BxPBAsenC>#xC1TAxTe%Rr07MRp@3^Q!*a246N9>yZ2YN>fJk&p5jgZP+jP9 zsy^R&Q0U^Dy39W>XE0QC8Zo85IE@?J;*--!|5#S~v0N_D@y&xtKqJ2L)hV`OXyTLn z!GWsim(4u! zPFO8AeGghvub%Nu9JicNO&sG*e$tva*5Aa(XyQqIM?Hs`2Qb8HaTsDry}FMeEkhB@ z8P&uo-sF#16Q^k6Hv&hYLIIgwR;Tr>&m>Q{XL>|WBjB0zj8KpC@y1O0v`;@odcl`@ znDhakeuVTPpMH$=VM~L0?Cd#8+7RM0J)cgrL&6c8G>678ZazJLP^6 z1%*B`eg#O%Y=2=q-ruk|f~p7hg}X1qv@1xH&=jwA~VdmqxJ zpwKHyhTZfqUL&HNPPPt$xs`K@UmwQ~bi2lL&zpund1T;|7y6me=1;Y{Nb%J8FkksHm9;jTSC(6tx)uD!^2ZBxJsU9n8p zjHF!G9u*>3*Vb1sV`SHiq8wkSV#XSe6)&7g9~C|@RgdWTB2Q_c#NiURGd&_2d+|*Apr83oba7 zz9eN+Iu!eLq{Aiolj(ftS7qP$@~6*H{xs!(rBeRPIm(}*{3j~qpE^hRrzrnGrTo+9 zDE~C&f4NfrnRAqXhVmb;lwW{$&IX?aY@}bRlozd?t^7X9|Id~32h1Us7j=+Q>G|DMyQq}xQV}&xE^-MH}L(rviT8A4mS9T z%BrUb)GNPqB0yHn*soZny0)jkP}G*==JH$Rwdtvu@6leg-iy?+Cf0`xgBJz;dm?*b zR=*c@bB&CNHD;G&S%yiHUpk-fy{$)3qrm%E6$nq1K#;!l z&kzWYjs(IpRUqt33QUkj_sEU5wdZZ{UyCRV3 zqt$|5Z?x3dfk1w)|N=Nq0?-1JL_LsYVm@kN;nM zb%bKkIWAXIj)le+#(s|vRZe%;NI&J0jlvHUOAD35B)?|9`yw72=evw#U3$3IePPyh z;B)i$M6K5?C%Egm{FAk;TZjcfkeTbpG`b9UVGjTJI3d3iEDmwh0Me`#O$#cC*afSilFFP2|0km!r1>YUyzDHp2w zWJ9h_%E;0ABG*SC{a~Yq>UoVibX9`)Ncf@)-^g6=GQl2YuFYedoP2S-(KSt(60@$v zM)zPg7)j@I!egD%Mp-rL(Fc>K&-Z2n&TF@KPO5xa*pgP5jkQ7CXE;dJ96shP(PP@h z&cwH7l51V6Gn3rlQmbZ?n@Djn#zM?<PnKS2%;-)UOCKQ3l}naBMEYYseVFtEK7EArCoRoQ8CVR*mBzw(SkKin$wPW_ zEb$3Fued6CLQl0{n@LXj^wXq~K;%zjykQOa{0HgRVL#$yq!0P@QPKx|`UvR-pFT_) zChq!6`ixH>ApNA$m(C=QsO%+IC8zXUK9io*lN-?=(i32!5OGfkmYANCkb|C_jQoI} zZD641IL9;ViG9-If!8tOLGK^Me$nyH4Db|Ism^vL_d#;ky4+*UnPd$~Y@v-jU`^|J z@P1nFodI->lIzk#6x~D;V`m2sV0$xIcBVPMRwNNk9Ek};5Q--t2m0ky62;o1Cacmg zkJO;kR~PuXs17T|R~84Rbq=YkTBC9c6u9}9TQjaLn$TKR@lL-A1m*n2yylftywEF( zAIEF;)O^SCAHT$E;?CNNub3(o2}E6Z))SH14bT(lzLf_~glmng`123~ZNC_VjTSgb z`=<Ys|%W zS}lG7;D{{%^kf=Xwv2?98f4!rClhGK);ca^ThTjO7txuPaLX_n=2oL&P)i&QYfT@naXGkk~UOJ+3*e1h2GfE zhT67vmXSdRSIwltVLsW+V{9hfm>?sBP?6@!B6~^;h8J!3q6W$Yiv_50{f5Wy-+f`N z#wtVS?plbbI#Vlf2S%RqU(Mlv^{?8*RH+vRBjN_G)NOdt{16*eyj<40Nyc9B8BSJZ zWk-u4wYhu17We>M7pDCX$L`c^bwme=YX$Zy$X9Pp6>qZQ&9uG2Z|c8UU%c65Z=Tc} zt7BGgtlrH?DF>!{De4fdH-STpUrsSjG*i)GZ~ zsI=C-jLs50dR9_$TkK0xQg|6y@u*Hm0&!09?58fbBmacnyP_vknz6(FS6~x&FArSc z1R)_hRP&S^hn=61HJn{$$6@lN!Szwt2$e{WIij(eusTp#ioj)(tEOymXG<=E%F z%Vl`eQeF6Y87q6(-vu8t8hkXxi6rQN-=|0)@#&MK5lohUg7l%JAWJQMQRk&@GNl>` zq4v79cR+tbccmb^RWWjOP*;$hg)J=tOY}K|<>Nd@!IH5DSUw7gWOm8Ia=}vI0Qm|=r zTiZz392U4eY-9`uygA_0g3W@`C}t=1M9*bjcw%<3xQ{yOb06Z$;Ld-S=vP1mXl=o- zCA0F>boApoh^Kd{Gme;H2}lWMa>UD28v?INhhi@x3?~0mVltGV$HMvtRoeIdm``hF zkNUKFe}r^?f^Y;Qt$e|bEiA}@ivog(3p@kP;$G85#C&%ZW{u0}AsD@OxmkItzxYoOR&Efq}dQGcVc^x=63cxy&V-J zie@6DlfqA<)>Y9lB44BKrTiz+f{eK(I49(K;`eT+DC}{%#9pT%NF?xXr<7UQA%@!Y z!9)npM}(yHY=tAE$4c@+YNEr~e*VSC$EnRH=wKipr#>6I2bfirpdzC`Rg#Yc@IvfR zK>~QMOG^Ikc1elfog^oH*P(pt`WSrHDE~1HfZRv=x~@}kbQHm2T^}>buXddrDF6Jr zex%a%!#=IPbFH#rw>p0y_Sk!pBPTuf0$a8(l-O(b%uZpirFIK@EwxwJYpI1;=xfOX zB&S{S5Xp@$d6?v8mpnpp*6W>j_KNF;#j?;)KFnJ%(6qz*@Qh$ZTRvYUV5;ToGUjJ| z{sWX>U^qgfPm+JYQv0~3SUC%^!?Odr`I6rUW8^H)LveRO$LC9tjU_&lTft|n zePBHRrBRX$d=@||lI;BO`SD8cAFcHMRHgSP13sTA@foeKm0eom^QH3SCizjqKc4iv zH=;R0&}39%_P2SMHHOd59b}I69A#Dn^HU`;I`vM@6JUYi$uDdc{3EsbwdQG9#`Vbf z4LYB3x9E9rJn@SX?43vVBP1fOP$MqqtR)uv?BqEsK2wzV+0{*j%gLD4vmRU_kEew> zo6KJ5|J1~x}U5#-4SJ24NlZzMUPR8**GSOWU3yJl#;vss4|%66b|lo+^vwFg^FW}8+2bVnp1?`xEHH1avg9GMQuSb3DP*)>dfXd%QtcCOgU7vIc--r4 zg~w?0o4sHmxzWiLQqxYXkjk7?A%&dU;XWZwSULxJa37QsjSH^8NSyCFCM;17AUwg& zHAJUl&8zQt)t61E51UgoIPb;IWAjco5#JUPX5N9+0O(u>4!Gts_@u$v4Bq1;H$)3@ z=?T&AZG{V*UgA-aulWfnz1}x{lb{aGvSO~Y5-X4nr3=rr6-6?*qWykp_}GK#Od^jE z$zvqFSLi6o7P$m&i$cA76NHc#6h|i1J13{9%|?EJJ7cvQP}{gv5u$-ntwS=(C$%9u zOfZy}k^N{l&gjk)?N|-#HJ>%+;V!CT-dI28@pcsET?VNMc|bBb%Am!K&Iyc!+2mZk zl2H6%pM>HM`6RP(z$c;k1)qfIpEi|dT>{k;Rbe#%#wU}Jpdl*vd_l8-2j%Ca z>3XNS<87WS;eh>gX&jI-S*dp5fXlK80{4B7lI~<)azIRX-sXS$o3`dyD>z`g&E?BX z{yJ6(Je87WsdGkDDXg5<$=T)zG-+XchaxLj=fR0vQaT{Pv&f2%&vmQ-djt(eU<6v| z8zvA=Qe+NXjM|sd_}qj?K-~Do3xv5qTqsIIYiEp2Q2BZ%;Cai0ZFIfctfe}Y)3%MO zBxA;-IUJjY+84EEF5b3|WdfK1Nx%bOa3^(mJ<(}~H^2`6g9is%U4Y^yv^AfL-j{6D zX909pLj|@0iWERp1N=US15<-7Fb1~K91Bzt6Uu7wLb+`KBtf_|xTTu>VikE*nK7== zJy)3q=eeJ&%p`)WT1EqenQ-BybZ6@^n^MrI9Wv!B{3~G;2vSJ0>11Dubqde_SZX&` zGZeyBb(1V|k-d)z8${6I*XX?O>9AHsishRi#$?x!v%)DBq*#=Jk=Fc$N!nguhYE*% z9KL+pk)g^{SLNy?PP`)!mb)O9;d0T5!}(?>ACY8C1`aLqFNEV2mOxl;C8-5n~RZ=$Cqq?;xD z_f!&31c3c#VH^Yw_k5QjGb- zjCrq;>ym>&Vz;TU7dkI!mY~N`R)8^NuvrgIEY})t|Jra#fanHRbPEk$4z#5E#Po1ei z3RK^r(k(TENGn#0;g1&50~i2lj&`MM&tVq9PR3jK?xV5Q(T| zh9FEtHf3ZbVw$qNYe`L7YMWB_7P89PmUpvKmM7`rr1?&zEN=%XCwulNWqFb)j))gx zB`tF=DM!T?rsYU`pWb)2M2F%wJ4?V6!x?oS5L{H#66h{!T6zxT_|c$gi6?`;Tx9-z zQvBW#$O(_PFd^Zs{3+*$w`b+xs$Dw?TDbi^-aZ>1ezHWj6C_(q)WzfC^wC|pwIRCJ z1%eDg9SyqeIEEM)f>S|`$jxae$z+iUPD;SKf7r2ZvDb=e2eofL016g8gd@K#MCUodnOVF^-TI9@};=+X*~L#p%Dbpn1%7kH)(=s)1q6a4uKd!6{Z=s{ zcvhi!im71McMP-Yc&v}svvOo*zs(?)@PJYPqX55^w#^#Wf%wmjHLO6u=f)alz70_Q z)-Zr-xhcjPmiPqn)6Q+h1a6&SmMjlbSe%f&Z8Hvt9u;`&(W44FYB6A2o(C~-RAtbP z=1>-})LG)OoZo-q|9L@u0##P(Y;ohp5mecRdoOH0*Z?{Vn!1y8Cq2J>ghA{>W ztY<50(hdk<8Y{|Jd+-p0QFE?6dcdL{N?mEkZ?j5FUDT$rNw?Ev3-!SLLw3xVfq2YV#<;NzKG(L5Hawwz9B8P#dd55bLVMN?kw?>G)(?k+l2=wdlYv=KXq

w`V`t%+*ZAR`$&Hh3CdURKjL1P*SF5j2R}bnKG^Q`= zfk9U@0WcvfADB^-NOoBReAorInG{7?3AWOlvgk-@d(%!ZFtD%QmS;nIx~i;VLhK(qDY;E+!uBd{Ss zm>UTzaHzVHI>hPHb%=)L3bqw5H?(D0S7mSQtt$EWN+2(ETV=>*1wbnEkup)d6k7S4 zc|mIH804lUzZvk{lK25YYfECigX_lS5GBm4W`*8Kel>SxY-muNpUw(G)>3=GLvEC& zkN(u5fPBO*&v!e3w3*C;Mbw}YZsO>PO*FDuxRk|4w@SQP9^e?^&9nhRX*R}@mcXSb zNPV-i2*ER>d^{_HkL+#r#vh;L#s=~WU|Aw2J?`j#+|j>$EkF)Q9_ZgvM*o(=x#Q`d z?|1c9YFbunS_tvC2^r5;JNh44v4L-;=>KwkP}X5(ltKJO=;~tR7S%UytJeLJ-L%5TktMIDp~Kz$vsJ%W^HDOK6P&124F;F(>b?o1AFVI$D&1iN52;Xr29O?0IX- zu06;zcPzXq^@vr{h&QA#xO6)?DWV8Eq~JF9Nj(pe(e=zhqJ#@ePA%;}<%pAFQhYxi z@9)PTL^U+HV1A@9xgM@Hbtsrt4;1B891K<-9Y}yy2k}9)%U^3nL*XhJ`_nj-1)7FK zM7gB3--v*Psm*4%!SBBf_dsoK^#OeCUykmbyXXD;4jejiVj;duZbI29C@E9}C*_l< z%XD-lF5B!ov+1@eN9xV5i=y6S*4p2zaYhtXe)&uP_z!>k=l|);cink!<(P+id$n!u z3SSXBh*+~j9dycY{kC0Vp}ncxh+i7#ST^O4b{OycC*e34LD&(8LS!}71b#u~lhyid z;MjulpwJY7PL&4Skz6&th(Bx*r-^%wccD-7=)zw=z zzafe^Qp`aH0Lk2v61i+XYu=xKIl8TfS6SaLqx%fZZLC%n{{xy*L~q;W&W7W68*Eol zp5U>}fS`Cs`Y#j&3PL0^UyZ6F^l5fmNo~=IyJMQ#;0jMrsGSv_HHgn+t|qcJ(~rSy z5au%=_R%D6gc!LK{o4h#ilSBwj3hXG(a@k5@5)!b!|qtl<9ER&)$J>K_B4|_F(lIq z@Q@Du^ggBwrU0eZ&6_2kvK_7~aKTnHX=~E0!!ihLwWB=XQ@DS~!xe#78ZabUdunzb zlIM(i@t19??p_Xud0>{5@Q^9nqX#Aq_{j8JW(pBpt|BM-iDFOO&({|vw3-L3+~Pnb z@q{M4QJ;lxh*!HC*N@&o4$c9UA#OxvV}n#?9*0^N4~wYG{2IiUGjo&tHVWm0iE`EKcCqB88V{8s(Q+7&9JfjhIt zy}gE~vSn2|Flw1}R7O+Kf2D!$a?N`qTUMpANvO=mu-qsN4O$fDV#KR8I|^HF6sEIA z9aQAR0oUu{j&K-qyH0c7U^<;gkhQJzRAik*elj%P77yoYVD}469O9?BZ=* zi;M~pDd|YcuqF_2tsr0{UtGOYB3}$-jk4Pk`TqJMI9fR@eJMD4-#$_3S1XQA8;{923=5(!vkyq4+1j#?uNYaSY zC3FxgQa1l0EDE8pA&T?=HzboXmjjAJZY}>@NY?Yev1BCWQTLdemSXk&ySwM7YGng) zSyf_tMT_>*{J)3lYGt8%*%95~E7}kx`QL|RYCX2x z8cs1^pq~G2NH+2_A=xa;w(>7nE=?qA!s*@)Tn8Cm66u%%16fg%8(i@-_03TQo+67{ zw(=T%+XZ)EgP4mvG0 zuhCPS3WRIX7;8-ixx@eyT*~Ye`RY((;y2QBVhjrdU<@czU`#P9aXBkVIahAzhElZ> zrRwDrH_C}``rHDK9FRX7CM7PXDlwF^xpbS3g@Jx*i@C2`k1V%gcw)(>bztq#X4y6VmtkIF>!+VB;h&!^jo*?Er_O)ko^%tJK8uMvQ;Q25WfZBS2_*UZn7TNs zrM1MJjqS$wvtV#NuGi}k=f4?JiVhKyA{`NlB^~KtemV*dMfDy9Rz-#DmQ3^i%aQ?* zriW03xL^Qpc0&Glp>S--?yFG4X@U1FU|M%Xl7>EsM-(bHA{t^dvGCG=RbXXOa*(vd z%9?f3s;!r~jS?#z{U_>8A762-z(y=iXjtS}mnCbt@Wqm@UQ0Ie0)?9%52+WQQQ!oT zVY6XTY>daFnULC}nc(ZuOntCq-Eh`0Zv1>$#lpY+lsp>EXMu5t(msq!90!4~;H<)f zTK-S>Tt!oLcw|~b{Q=~~j?-Urj+OF8KUf@vb&@@MW3;A<_qlpwq?@alDlkszZRc-Hnxz$w7tAaz5ekm9C5mawPMlZZ zkXkJ=h}em843#1S*2Qfxl?(++|%w5g~e033Xpgzz)5EH0{{`q#H zMY-5&lG?HO*DCpITU(-4ng?LV`55M=q&Ze7cMEa+dl(chL zWwdq#`Z?J8)-vBz%w;vJv>u0B-SwN5<-vaAt0zPu^t-Y0^0kvm1##qkTm%b%mq3iSq2AeVC{Q8+-U!9j84@_K0>viR3m69sK*V%+ z=d6e6;t;msx-!+Zwx~-Z)M&@RQ9-P9O6*IPz}p;OOW|t+TCAUVfq^0tc&$uea zM^ph$j${U!xhg;TD_2E*L=`}HWEBEJPzB*9Kl{A!lb^2g6BxyF&QIVaeiHjRiSdal zCow)z| z)wb=aEmf&Ai&uf`baWM9c$H&3<*GX2ns=Dy%!PgV z=l&$V<976`K5nGYH5Pp4p7_20_VM5Oo1b~`bJ2c2z)_igGr3=g_V3f`O6-Jr%mz1C z_22#FM?S>Ttn$UUq&!U|LI@<@y{IlJk?kYPgQD=Ze(s0b?&J@`>oHimBG{k zfEU*F{(tt~1xl~0y7Rn`@B8ZURh2&3qFW&MH(&!Qu>uX)_vDF zG%z{iYP8epWD0cZ@P75vZ~JY#?rx;>g5`_%zx!3Bwy9fu+4M?b>^omhg+4agb5vIr zi7&dw9K-5<%Wd1x_x?}4^euPa|E{lqtc+@`t3sBl`NA=(8LB~cRTfw=`J7Z#!a5?R??OpZfS$@4o+S-%fsR7hSb61gTMW}hWOgb{0Duf1Qr1s#$7a1@)g_xsYFPxA7gj7Gt|>_{@R_x!$NdoHBx ztCoYRQbWu0`y0=9TT^GFjsWh5t-^J*Dl0wzU?=U}XCFVFr&DnYt=@!f%&cL(l#Peh z4)zFeo(b+X=9sN@&P28ib(O7ft#EFz<<69rJJTgPD~fM19Tqz}yDXhm_b03?RF~E; z+sh2)6D?bAZ@~^^nfk01*0IgX!pit6Z<$p7U24Ki9@?TLU4Ngwq0d%2OEIsNEod&3 zRX&+iv=LsVX4bl#Sv~ogQvb7(T}mw;SeSHz)|{2=@Jtid_@`Q!XewJu+WUlb2S8$J zfg6gNfti{2MYUFwlB4t|L5YD7RYA0ZSj+6J(DF#LR#z*oD^y0SKdRGuNHD~$((Z@S zG>?OcwJJ!?dN4DwgG|0fvRGgLqVh=~DYXsGV%11_>Z%bH5`S|cb`@0Dn!P@M$9FPVddVG*bPjno~14d_@Mb$D)fg8|&^KwE9-- z?oBD8Vk1*v3Gx}3vPRbZggLmFajA$fEJR1a1?PB=p%#LZ<8@LV|5X9nyTeII^DX2p zA91Y4MP=O44S&!O4B^+!2tVrfXK8OP(po`f5y zJRighZ~eHHLq=%O?bCk$gk!R1jeR;0KZTwx{krw+b=4$^tODTgNXOa~9C#p~8G`(c zA2kTh1(Ue>z^RnL<8xP0cZ)N2Sw8=;HZLx|ts*b+{xKDY6;`<`0`coI6PbSJ1(Ya@AVa0 zcsXKYWk`IBf1d>~wj_nt-;Fr~x{mqu9y_q_KRG~ybN3VPwUL>ljgQrMB5*n%YO?Ya z$?8H0k+t&pIWMmXzgbDgZFchTJujn#t=@-QJ*)VQv8a z;Z7W^J8_Rcu8B|`jhMa96A(cIAiQSW)VXOji6U*6E7VUxqAdFiJ$;Gt>ea_`c#iCG z;?eBg?~j_KDBc4@d7w^$EHzCI#&VIwPQsO+rInwnimnQuY<2byAvdhf-r0D5ub#U% zuZCqP*0tpd&=YiGhSgp!Sq7CSg(ig|-&&(|2tx=7x6-W;A`^65KgL8?g&ENd6%~i3+ z`>hKl+BWlml|`*KSGNJ65*%w#-%9I+fD>m(D=ik_i9?G8>Cpqd+&tXNf)B1H5B3rO z#k@e(sFM79zA)gFR(Zg1Kddz}S4%KoEe!6;a=%-7M01QfoAod4-6>*))**U!m$X5~ z(_~+|`g~fLayfVru0kM@zZEaP)BOmZw_JYlc4}2D+@_x+ZqoHtc<8BeYpy@+CF+=w z2UY{~h}==iA+C)_n7oELR*wIo<0YdVCtH&5wXoXG?%L)3_SSo;I`fq1HZ4bG?E&V# z=h?eWsp@vMkc1TWt~O28o0Kc049H=gG@@afspt$e>>-oWvaN({sKksLCZ|uBj#M;F zPA|{6y(NtEDl*xa3k??v`?UyiuNxDfPv|Khs!o8iu+C8tv0<^9aG`dujB;oVJ&fZOqb4-^PZM4 zIk~BCh&+z2NFEQ2lE*2CS`A%OZTf0lq$cIx`=gc>rYJYGrW2kdjUOp&i#J!`6~omO zp_45&R#TC+zHY+w-l+c~iTn{YlHqFr^jZHdm6YZMP_@Q51%OU#xW0Qcyd=o}4~nmZ z=MU()VV_#I_jfhq%?j&hO{Zpezwa_lPS`K?1)RDyJOr=tq{a{&@p4kJqyFQ0q%3WA ztQ_($vMn`-Y3%CxuN$DRA}U?&jiS;O=9Aggh8ae*vmCO8b~LKprupeS!-qO*1vSr* zB0efJzv`<_$;^AL#$lN`Y*>PiR+`dET}5e=1fkADce%18Mu}7oZ_XQ~!Ed(jkvgLi zbVxR3aml>g7m5_Tv5r?a-OlvzK>v*etN$=%)O)gW54kf-E(8c!B`Dpz*0Lv7IHL%8 zPi<_u*V-PoE)FB)EUU0b&vdbH3UFL<8mjccGiB)C@8pWQy@ENeX+^J}O@^D1Zk5P( zvhzlJ9R8X&e{gvTQ}Ei}%fvyLsibOzv6vro!^2HV=H#Fx)$?2AzW$tl@sIVf{c2AmPZk;`pEq6+b?% zxTEp@aW!aje-MCuAF_dX$^Zaf!-L~$(BA%F4R7=so;;?8hsV`mTQ&p4@CRPQIb&+r zXA7R*;6T{M(?AV>xN;2##?@fkO#?Oj<;pc28drn7q6YEscV5Fdg1mQ34Ju!mantT^ zHU!a#43&RlQ+mEGT4a*)H~dlBMxOV0iZ)_`UHTLL zrBZDUTh`uK+obQK+DZx+?%qdkVTyMD3#J8U3t|aNDJKG!`0x67^RFF}B4tm?j(T;> zG3mWyH>H8C*j_yXZ1sGMXTMgsJ59FmY>#$rFE)O)>ue~t0aR5J-R-O%l-!z6Js&m? zbClFoOngy<29Ibf%Ofw$z6w5RS{$Uzw9d-3&WcUhQc`BmhEsb(ojI>cD@M`?U^oi==0>*e`?E*g$OYpX+1hBt5xH$#HcU7}-MM^R zwkt-mJ!f3D=Z<81{>FPwxmL2`nZBkJqa_8L%zg8K4 z0)02`Blc0ET=O@xtx1JH*hIyc!ys$DiSl{VK|t^eo3e<5N+IB;{7bSjoyWdpnHfe| z(&%W|7KKyQw<>FV#O6pew~YwCIU&I2!hOmy8=Td%H8>Q&Y}xLe>%QcyjLpdj;1q|c z2}@Bi1|c%CE3asCm9}LhZR@x}zIY_tCF8PP+Q{Z2$s}0!j&{AeYLbO8yOg>UlzDsr zp2;7+BMTYQaAMfOzM_${%6H{Rw&#w^_Pl{?**5(VJr;AL^tdU|V+K9ur$mnn!}Pd$ zK8GIBq!5Ez57HT@M}B6&(hxn;VxY%`8cwIjO$~aqY(|gD)}Tl4kN0Tnm>ykx+CADf z+@niQuSc{Pdc=^%ar7Qt+~|>IvmPniK#xQmFBmtJSJf2;mE+oR*{*M7Lpib#=!{1_ zaevt^zE(3Z?t1?5-m>egc_56Zm}WMgJ7nu4;sB%F@ys5m@7oPC0u zP6C1WXx#w}LW66fVY~MJGc|+Ucv2WnXH)a=KRpP`j|~V{oW?*LJyQ@)MvhF6dW)lF z2E*DBW|$O$=g8@Akl-A{WoO^e?v zC$mKUhV}O)-z}cPWVT7Q`wom3>)ohI&0bYm7W3|@!TxpMT%0}~*uU##1j_*IC;a;a z-v?m7`2&uh2)nyzpA7c*of6((=Jj?5VSiotN^5wGjzRsKJcBk;&ugOikbj@%J0{gZ z6!ZG_s$O2)Rp?}0IMLX4TbXp;9V#8{!V&)}U)^RGS{0QXa@x{HFi(VZGNLdpM2VG+r~fye5)Iw#8_O%w5WtMg-39>-(0_w~ zp#MPv8V}>C(fKP@fpLIH>XB)H`54mqL#Kr1zx2KNnL#@Lntxq8o<%<5siM^ZG~aTh zVkZM^ra__&7TF;F1|Pau(9T@B?0K78tW~{3dvN8aJnz40?7&rdhj!b_zdA|1L;Gsw z!=88DxO#_n(8}A~pnVw@=33E zZV>ibqCI+U)R+!`?uG7Aj-SkXviptb6U~*4iTCs^={a?$j115wr*0TH6gTR@e%Q;U&NQEw_`?Rb z){Aj#ohUDn$b9TL9|xhK(8uWL;x3OZGzBKnnKTnaX!s!PX!g zB2JGXGNScpKq8T~AqdZFzSG=tGfZTiVIpgO*vtLyRM&#YJSwgGPEX)Ftr6sE9gZ_I z0vg20#*hnp5RCsZij(+l`<@&%+8A4$+&;m6OYS$|uL3g{sb7y(ZORe9xUnfW;gNRx zd}S+LS#?=}5ewIOrQ9~*U$qs&v1@J3@iV*?5-45ZEB3wx@2$ZX6w1+`4EAp$*3i|M zw_yh-YIJ8e1fJI>`3w*9K7MlB%OA(lyc+)24)BJ%d87bnrP|;7Li@2~FiZd6Sz$2j z8i>Oj+-0L5xW=38ZE#LL$LAzd)7gf_p7n|Fn6ix*a;KBAmYQ*hJmx2j;KCgV8V`-B zB3o&$sDNF3bPCK%_YzQ$i1M+PLFNVJJm35Ng4lN_^GC-rsrK+LxG3NnHCDC3Pa3SV zW?LppbY|B&zO6Z4CLace>vB)8krsyb3Y*vsfiQ;Q;Tn5YpLn?23C=Od>qI9_es_D; zmJD?VN8mCBr#&Ele)Lil44sJ-b*xPQMN^n6e16ASi}Sfo2%q2Ja}#6vKLc{EvH#rI zzul56-Jr;Kz)9oe@BUV1Gv4kZIQz(0zTwP}F@yz$H}3?6fmNQVrd1PgulwzVG6nAr-Lw%aa^e_U`yORh4 z2=%CuY=@2Hl!it?6c5Fbj#n&j6`&*?j?{KMRN>R(d8CNt>c(N65fwJ$2Vx^-mwR-7 zp04zW|AhB=u6cbgWS2Ig_uI{XIMM-xB9Z8Ny?Oa2e)hLM`@ayk&u}wiLRVMkhRZ~_ z4U@|aTH(q(*b7TeE^yJkARIb9KPOtdLYweZT!cL3$*xSqYrG@^v7E=J-FcjwI;1w) z^>>bD?=O0xSw-m?mtLgvsqx;4sHb!Y*MgoxSo$y`x|h0v*D-@Ih<0<#)v;UQbs{Ll z!s{fY6wt`!pG}*pfHx2n6d#!*^l>j_%b&O?bVg4L**3=@d&Pto*E84m zK|Xe<%qr#1p~-@$^L5{|MU94%?AxLg$7Nlmqy;*zn(i*dSrNvwcmK^mm2}_Ou4Yel zj0;sW^SqB@^1$GKxRXam<1sWf=MH?jF{p5WIA}Oe5SG0;5oWG}%JcHKmJn$3oB~@?ivQiU?SZYG+oaS_wZR&CTkx z2-olMk(<(u|5BpHb@EQD4NH@XSbIrvWf2_^E(w@>-)?c-93xHjSV+Vq0xhu- zMYmLd#I$T*8`Um_&r_vR6WAw_%Vi!l86B_-NmMgFscNeVDpi$QDQPlxV8;C+M>_!2 zsi01s;|Cb%DzLRG(ARXk$7V9wpdvO$f9*JRKfR04XD9&aV){!P=DI}HYPCBPlT*`O zyG2M{t1MPCVq^pUkL#+m^&@G;I2Sk$n^jc^Z=35(2qkWC(?`T?%;Xp|5T=#X0HKkD zF>ok}$D)5lku}Yed`haqyEZZtm$t6WKx`5)|dCGw&W#p1Dx1Zv2Mm}z?>suqM z2cPI~P)eJf^fbB|(i(j;+lJDgnoQ2Mr>Cb=NoMVu^u8>D^$yxn$J*q)UF?IBlSX0^FeY7q7`e0Na*ermR(ulv%vL>w-LHFUKxmH}Ji>Bh0VZ5%|G)b$$$UtVW| ztBAD##T9j?2*mh?&YVD7YRq_5qf@aln?;ErnW-TOJziH~qdAe4cT>F5Zo#ZmtV-Mz z(qhqZ-c^`*By|myJkfq6QB};B5XOP0-h2i#&U)`QW}GRY5x2kA(!ui^xucbnpSljD zwd*PEp-!Onqfp0_Jz>tN+4Jg~#eASP*JDtV^_B*J>fP2l6WW3kS|WPWL_ zmBN*BvP(>b>WoXnXT=7>wUbe0&h#S`+pFf20iDi{F0QN!E1a4sPN>4JI4N&Xh1H8{ zA1r?~V4(%C0@k1w91i{^tnO9?>%f^~3-je_a2*;@f!Q|2hQS!fzWG=}2BTaJvI9eq ziT?_+2X9y%vPVH-=dB9Zy+eR;VWr@D{_0@8_RNQJ;@Z{l^{_|r35%J$B*KHiwR8$2 z`6;nR=M9t9$tJiWoQ)L;2@-s^L&B}Sy}nIGp4QRr$l=MvmxX>3c|0sY3JV`Mk7T=G zT(+$v**5tG6IB%IJx#zP!sco`Khm(^OT~n9w6)%VHJi|-?0FTu1uZnT6ddGj<6yaD z1eVLkWxJx04Gbag^FpC3B&)*`)xyd# zP%B254uqBXdHBU?QW$?0o{&{G{$;t=hNY7selp;5}6t7HZQcN ztMjlIw1PbxRfDf-u!px4mYWaJSfyfK2oO;L#}|^ESLHUtQ3q0&@eqeXS2wFm=haX$bwxUijleg&(}KmC`;X$)cy*Pij1mb$47 zrS+*vM%=CN=(oSw*H-CQJ(3|_oW^6P{L7ek!tPtNypyHh5J`q9yXVcmPS>qL8txA)vCLRdah3L)|47V39C$0u4NvuJ+fV=qXpNOp{hpVzRH568j5ivkm1^ zDbkJkC22bsTq!zxrXcMzo>@AjOkw;`X9lZrQ)&46Gy* z_1#2-^7*>|O=@Alog$rr=IKiVx7bSBQ8qSd{xoleK|b&W`NA|?G1wsEx45Bx-^ z&a_aODYC`5(IVmC^PMS;M@Q>AVg-KmO$sB(zn5*`?L0Qbs~rS3L!taoi@Z@Z4lG(Q z&!Hy3++ZacakJPj%H#-Mwyv3# zTHCdy1-dd_bPfvTO=z`-sD`cbxR3V)x%d2*EV}^uS?Z+>n#ugMO0k~pcb$ulFw|+_ z@jNqq+vL+Q&BH*034!HVZ9=deT{9un0X-;eCIoYfF%tsoNPPZW%(P6*h`t;83Atu` zcUbv>d_c?u6T%drDnyZuoEH58jJ&lC!5EHNAw16fM0I02PwWKts(x85KKh8?8~1=7 zt9g>0Ig_EDn3F2JUFc8svRP{soQLbtYE29@J24^NgnH5UIAGcM76-6hA*j3W}(@PM`>4UvRQV}IY6 z6ktRFZ!YB4fNES18?ekXE*YunJYhYl=OM}S~q36nvcs9Az|0W1M( z5lcu0SOWT%bak?nhw&tXElkyH!3r5$=!~$1DX88|CL$}nG}r>{s)1uKqC@p&-piDA}y1GbP{-yMVV7y+5=+;OIcN3p+6U;aw z>^Ekd`E5|Uvr7@ja#m9ZRY&SLh&nY$9l1|TnRg^zkrjd^*OXabLLnc26S1aC@j^im(YwP2hmFs}Qb=(sK9u%5i*rB6Ov~-K zQH?kW_S%@L8hpYRp zR=o(6L?0L*MnU7lFll_w3*#eyiFJIkb6N1Ek>R<(5O^{1iG5_AZUYlGmxX$jgtLLH zN}gu5Jbkr>TN$=Oc>G*|P9XMz?540ya>cmi$t2K%KOun%N8fo+IK|faIIgQC5Z~4Z z@oK!eVjI+Yz8rJJPIy|lNQQP*vBijzG9LSJsLLAC3#t(XSShUrs4t$h2#FBcgcktw z39{Zyzl?~)EAvrvoH1lBO7+SjFV^aZFYBBdcpAT5P*a2F58DOG?^GMPt8WX8%y1vHhIy#8`yB0__dkval};}! zED2wZ36=654o!U9`!F_2-IGmk$J!vOke<#8+OPZymxBGOuomwydzHOVCUs<$uXR*$ zQ>vUYVzysvmbs((@*&gH8WsGySHL}~%?dtf*#ZOyt%9PmLxmNZK@2;Ni?mWWFa@ z@sm?B(_L#B4m)fk+4Kn(9_UQ~9F;z;`MtQ(=ciy%ED5Kanc1D~^D~kqYwiLTx#Uer zW6w3&c(S|3?8&A%k&T(antb+TsrIB2l1Z%sF?5~-#{QHfjIO?3BZt10R$hz>5ZjnZ zX<2Vx>2zkRl5MTAg(uWEd<{W^`b;bI(eA0yk;j&ng2;uI1-|BU?|mjzD6?)pt<=r( z0QyDHDh(;APzpqX0{Hc|&sHGMfHI^Es-^5UK85D*(Bnz^D?28XhST$QtY0=2B$IbqKrc(CO0W{OM0`;9uHn)#{*V{clr z>8kM$0g{;HJ+;Vn92^>qT!#}9Jplq5b*r@LLum-t0FFI!9U5VTu=~#G`d-EwC*rK_cp><-5K0cRoPilA$c@Sx^?LfbId7U7FruMqBYtfz`m#-zq1r z)+R;4E{n}OS0=?(xT4Vbi5!Nbr2wLwylgh58B0_8N`J*gapzaOGB`~)2%O(@S@vd0 z3s99H^=$~v1_LE9gp7i;B=yr#!0KE~_h$`CJjjj18B+#`eVG_Hy7tr3{W%7nPI&|o zfPmN#RT}kQy=emxrwLTmWwY6glOmaeN0$P=6C5-AjOI)vDO1O>w%S?b7 zrX2L9>0r1NaByJ(W`N4w6vlm$l1^EzpbZ(HuNT22_x{O^}S?X{-0C zsC*N|Osj>zH8yW3!S+yNg79Hxp2j?s9^z)Rm(jKj`DSdLREKu-7B#S{Nkdp|*w7(W z=JHypB3TEWB&-@kz-y;nVisfpyX4k{!w>&%z|UI){tO{*g}e(SO8Ip!38-M6DA{)I zO}!vdz2IlK7gf{hNT?EtQTK(9_2!ezKcdrmwnZ%9*zQ82|L6cRZ&8ag{j!Z^z%V6YcoFgt>&){mDH=XD=ok~HliFa*oc+) zKBepL-7n->x~mhH`;Fb7BGdYQtN*~AH2Lu3^0Ps@psLS`e1Y%z;zJ+%)W`3-?a$uM zrRtb#?9(^?;uR0Q<`WOR^UL}qdw+{N!e9N+pM3q#?)$nv$rRIC{LaVzaQC0y{rPu3 zq)(C=3R@)^C*`{LH37=U6IcL#))TYxlWfkUF{!dFuq#+(PtUfA6P}-Ml_rDjN4rO| zN~q?_z=1{q+g#jw^PYZtFLuM;t-Md}?05F|Cl;S{E6FS{c5-`GaqG_h#NPg-a>%Zl zFV5atwD$IMPh32a?zK{rD#d}fqP-VgH(y+PYn5lGnwU@pY%N&@Bo&=ld);JJu4u=^ zx@6UGv!ohTTLzIJ%PC6jYMy7>$ms!(d%It2Wi5udFpZP}h2XZDWI=DaICyxNw%^je zf3~f}a?7e)BUEVbj%_MG{<7bfoBEL*4!#5++EQUwhBZ#l#-4 z-!8Lj+~$L%iJ5+UFcT6`q_stex1xLc917e*-L#L2<;iGCoVP199y|L}dg=A2)$M6L zCp-Jod;8s;{h6XG1o$F$1_Zd#2vv?j&{*0~P21Q`8f7A=sg-rDGz>IxL(j#yozptI z9>`vEh5!?aU6830G&l)O2?qMe&w6x4K`}p_ZvzkPEF>JBE~fSZDd6gW)C3T6UTzj3 zc%0!8Fe%Mm#3*Fbd#O;+)5>(w-OGtBIc#c3K#0HuL$*xU+@c5G%V~PS&REdF+s#o6 z3#8WeYT(c|t|{6+CiswKHg;KK>@tm=nJo}`o)464->%_nFxAHdBkn5!7hy$Ou46A4ptSPjGiC(sgDBt~3+v!(qS6xPvZrV;;k50=pS%SY zP;_40Xna1&pzy_Azoxqa#B1*0Tb}{lj30O9ubPQwHU^^>Kp+ zLb7sd)sAaJNk(Bhj$IGA(0eFovsFL;MV+!zo=pma(wh3@sie;-Jc#MOs8A4Bxl?0k!$o8A3hAdrjG2ll& zBIt|POFgA4h|7+FW$qJF z+Z(n!W~FU$$+kNbJt>W>F_1TU)nAar&qk9c5R$q!=U3Vs;3%lW~fgH6I0_!BsPoo^P%&L5~<~Yz3#%~RpIN) z`8p}ztBUH(C{L!~%x?wo%DFImlS)H^2Q{et-l=!K-x0p=58rQN7NG>F9b+wU7~?XG zqOubxR}2eKXLwR$6YE;uZAV_Tg1v`h<>J*eiQl3x&J(3lT4@@ogSa`%{PX~1b^X3tAKw%D8 zFPX_uH@&?OR-SFsA7~@EUWZ;>Pdasgm_5qMtjBS8XF;mE(OXszC}x}9uxKWRwuo`y zt2neA76OMZ{0VL$>?19d9_?Qk3+y>7w{n_I0K=<< zN@T?c0w~~NfdS{u?t{!QpU_x#ezy6d>^VF!G*|NDjsVZ|Cm?!(KQY)Zq(&bi14#j@ zvhrBuU)tHaK0+iE7)Z8_8Ca7w*19P9QDN!)WI>F{#((C*>=F3a%<)c}x>m95;vNh)v0m!Fs2BGV*g-uJ7v2QaC z4=o8q*>X@Gc*{9&fEsYX9E^P^QI$mS8V5$PnCQLNG%9FC@8yzbO?i+uqq6K{6slfA zS3X<-fn|LMkN<6c0x)=i!O~Q__cGCd7n;{TH`tl5f@X52_```D9w-EN+jX@POF2yl zlOZ;sI$*DW_9|5;CkRZ{6Vb<}paGW-c{y>NdII(7Fp6F#)s5)QtP7>8E64G0OgD)I zkXsZeYQ$xjA5$K$Wp3y}QRBcLo$H+lTAI*o^3jIC&>dY@>%rqGQWGO?{q;w$yl0E$-E25g5=R)Zt3h`Zr2$O z9bUO-W#o>e_;Lm10@`Ui?SN=}LrVTc65tCgQoiNdG-#$U4~FQBJY@={6+8qwgR+pY zH%?x*K}y&&H>jQ>RU+DBtLh1R{DB5}LbGSeM*#F9$J>eqO?PV7H29L0S@OR+Ui=4X zLuIU)Yo_TXR)E520?CfQY2pW$8X?`;tvDba8L&Ipgw`>EW1{DXXUe@xd_+r3W#}y% zdzH(T)`fu+<0}Mvg>lMl3Du}jd+=A9Fz&`=?d=uwqgc((zCV-IG1zCI6oWW(?KTqw zoX?rmG;f?AK3`7Y<4VkB1}y%yb~dC)`SPn$~kNsB4=6Ptd!1(Wm2=eE+)W3b(@AVQ`zRhtVG|{LBaNno!URE(7=RgZ*>q@d^iZx z;?}ExP!^1DM2UIB5)adQSuM-8ROldGK^iOBNw2i$-FBD^p~F}{ubIOTI-KKzLDW@P z-fHa+4cfB#p|bmH`f+1SxOHI67_)e{K_1D@&ik0`_pg-wfw9@SnrD3N501^g;JF=H zMlcOjfPHK(1J%hU!Z)bMcC}NKT1y{afemuNrRR8*2n3>G^hrJA8xUEr`BjMO^k$jE zygFTr582eRV;esN5Ud+R^SdI_EgF)+VKlVY6v{TPR(oyMORLYr)ltT%6oX_U=S$*k zw6POeahtc1^V#%kz-KuO$uWl!RU-!tSf^qSuvz}Hek>q z%g)M}ylb^FX)3~WwG~>2hSWLqPDWm%<13I+3TRn>LzjO471kx4?hjou zCkc?GWjBN*ytOvIq(Yb+VhksIWmE=0DR<$P_X~Jyr37>>UJi;{+&U}3$Ozab3Vhgr`5k+I;7}wFz zkpGn?5mDnxx^i&zzdEQURcU+g_oOg*1aOOqfCitxX$i6ATI4W0Pfm zU4N>axQbUcJ^RyTdL3KcmFB=Z7b#MRzqUoXMrXq~9LRqu_g0m=c*(CVC;l6H)PKEB z)=V9GDz)b}c{Ro&ep{Dq&|&uMp2hVy^t;+-pKTZ2yEvsq9zak7QwC!ReU zVq4gSjjxab(x2g6xb{%7AaOXWcZaNl+G)kbPe0srhQXd3#M)@}UKLZ%^VIdEVt^&8 z47OI$kkrT)2ONvZN4SOtFwgX$*GT|an~;WRMGnWzTIG>elw=k{IGZ4lv25l=EYoz*tDzUG@dg% z6th6L$-PDBxvSs1x<94!Z;kqP_tzQ>Y&V|6_UKe`j)E(kBXG{aw8t5_v1PcV5Phbr zJLb-%AFX1ow>m{=JQdiSBp#vIZv3sm-)-4+-v8X9yL(<A|4dpC z9%u*l$`T9i()AqPBeWr*2Ow;e)gRez=Lq8^m?_O=J48&4XbJSzZ(#Fddm8m=jjIs? z=;Bafnfx^80o`GnpR4CFl9oS!X!ENrU_u6>nJGFmDD*JC5X!{9#T##OIhF1PvnPJ= zkKg~^-~Q!$Pb7PG${8^nGCcJHTE#$8Q{1d%ku3_0rBJ6eEZ??MY#4T`E?Yzm3NtFx zP!LEKsoNY8(JIA`#R(Ng+G+=%{&LBkR6!)+oA?-pZ+?wA{pEa`8=z8X!dMTXO7H5= zKuhzuQj=?xaDDPXxX^$Fpq(t}G5?5odxEHFT@B+HCit!quT>i63h-ObifV_api`}F z_CjVm;B-?vUPPH1EBz@tD@Y(vhYr80Y$f*qU}47MJ!AM?Fl^lvtrExsNeGQvydpTjpfHvamMrWvydnRoaOIb z$h@)q{46BvVt!n%*Li+ocNk?e$WAieEzZ`2Zc&=CynefPOW#EgFzfAhBwUfGG>mng z@EN$mytOBrxAtW7c1m|q5-97LK*Zu*^s6J7fh3?>zE%7eNklrW%;W!8&((g0;F`8s0F4(38N$sQZLqJ^O-{K^qXPP1>CQS0h-3g|jbMA3K2g1baKH=H#&c?*#0?`d9JhavozQ-bb>M*JnmID3Ze3|kSE_HXL;p- zh8Ln_3Wr{>T9!Kdl5J~Nla$0Hz)F_Rrz^LWt1ZfL6az~GXE9V2A^C{t#0?_7_sQm| zB6SW2@;FO|XU% z1F#t(qrJgAw|yQ}?$H>lG>`0(EAK7SC^c*BMBHfTu$hB-RHp|V3zM404Fi3@*)!{S z^8;b=IF~51za|tX5C?!VQ*A1j+i(*Z(=o*YT~9~3hD%>4nE6WdQr zm)mISY*#e0F`*zFh{+gu_jPIIgjFAn(4|ruMX?F}vC+Y1-|%>HcP9&Gqb)rA$Wn5( z>}X3CVKUmQ9iA*3P*vF)K2Lll1U0Nk#BAPU-F;cdS(4+XhVu!(+Kv2D>F zhGX06(@<<%Sm$tTTYVaeZL2dJhWj_W&R}~LKMPgyIS)~7Lz2RaG8~F4%Gf+T+a`9d zsJ1XOMYTnrQRq=_sAxpBRRG$KMYXl4vKDf7Dg>~#prj6||u4~|Qx5PCrJxDa%a`0p#i>00Px(37?LQ-*0>=Kg94O!^4 z@v@Z+5y7e!{lS;C^<^t$vCCG{aG66=U*>?IwJ14&#i8ToWhL~@V5FWl&P)y05K?(>X_wp zhxM2ZQh|n~u3Z&7t3_3O!FYZ3^KvP znwteVCSVFB$~MOzeqE@fJqDxaAz^vajV|1{QQ^qb&+BtAIjZM&gqN%ZSsPkBUlzt? z;(6cilp35#ZNnd8?sAbG9KIIuq196GqN>fS3ctbbeaO z5n3Y7=miy zs(vEBnIE^kaVX4aaR-|#a{3DWRi_*|M$=GtKn}ERI(oTKUgn|Pd;FqtGwOc*6fQtJ=M9QYg)(5ysZt?dzIV9WxHf3+sTLJXk%F3nUBuA zg>g9FJc9EJ#%0?&l5N|#Y?q8=8?_5C&`rC550T%_>d*h`xWfI7f%~l?D#JKZPxQ#R zWey)?+??qe&DJ;sCdmlR7UQyQ8mjH&RMwag%lY8k?~Nk~Zlq|!%IKdrE?Y5@?VpBR z8(38Mn114BBu5{NIxrGZa6=Js!hQa@j=f-{W6zV-%9z@7&xomse?F&zkGJQ8 zh-`@7JF{_39W+CFr8l^CzmPqc#?0I2vQ)cQams%km7c%<2aXWlaw_I*v*#Z~r?&Bh(DHN;-up|Kh7Zf2wlP~YoJ7fZ?` z^zyZ*rj*>9)wJD+ftfrFjJpgH5-WeG|vXJox*h2xY8EOpoO`K zT#?@r#mH3*wty9GmgTXvU*sGyd^t#s|h`Jl4#p(YllC3z$4Uz;8;cHQWIef1EAi2+qwlPa~_i zh3q~S@D&qijM%dFVg~uZfo9ff#O@r;_@>61EiK=~;i8~j_^KvynnNl%GT{!7ZRPix zt!Qu_X$}tjJ1(%{`;WW9d3gNboP5Q0q#*~MFiAdPk{r;n#Iv3-Nh%aunVcn)Bw@I- zcy76lEa+-aP_dGEw*9qX%pSIf8B}0gxgPSvR;?7BtoOOSvcIv*5;j}7Gs9M`L_8@o z%x7*ohl?HjkpkZ5?I^M?uF%P2Nd)OF7JrtHy|B~oOctg*#*FC>029%SDP0(8 zDF*Ig_RU$ZX~t|i^^EuO$q4{M`2imvs&z+z)V%{7aswGa&nx@}9vNR*TVOtyd9G5_ zH5B@Se*!zl?nz)aM?S9Wo7WPDD=PsQZZb)9?~6Q+++(P|&KoMT7{`Arb7YuBW-#I+->O)^=V4%+LRbJ#isiSBm@!Y}$&pXxvPHUj2zOjEuLY@-lX z+G0Vc_=n30^5-$@W2@C=L2Rt_-k?=QEu{dx+fj?0mG&h z0=mIMGTL*wkTzow$J+!4x5Tbsjetjk52k*u zTL)@w2H@o&K(4(odx1Z_K%hR)>KCZb(UWp;m_eXESMTFMy#ff94IhL|u7DI=d=+5V zS7<$6!3_)#rUPJqc?4`9tZjV{Hz4ahSTE6+W0@8rlW2Y9PvEyENt>^CjfsH`WP%q3LWaFevej?e^=9k+| zj_RE*>cYMkj3c$E!HNit5ikhO2x=l!qy zdYU%b8#46C5M;=C)N`KWIlEs=xeVQI`S>V-Vh zOwlkewE{%77gLMh5XX1fFr&<9|unjUd4N=q!>*?hat6pJ76 zEGwozUT-lu(>p{a%cA!l4Pv%U44uFmwlAv9R9@?wH+a#NJT|zXP`Bs$q3CEXAzPn%0TyE@G}a=U0r6K28O@ZsOv~hR*AqO z@BZ@;LJtOU{%C~IYHCtV|6eisU&>H3E34C%>OPB__DwUJ%b|RcJ$S>4qcaNj)Vobf z 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%0DUZEF z*olq4AmZ5;k5C3QYWD&pM`k(_NSQ#FMv2W@IEtM?i;iA&Bd zQ;LPJnZ*R>UiKU|zDmO{okceAxpiqa@?T3c&I*NrVp%YOacZhO^m_tzerhqZEiQ#v zC~ht$_rL%FSKAu=*wz?3wQ)x*EwrZ%{t?aiYRC#$=#ZAShtw^VEaWE@28@A1=Etc~ z?sN2n&8`32sEd-?;nqnBS7kC`i_N~Mi}}6`Be2$}-ASH2rZ|}+mPUuSc�!XR5%& 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 native_msg::MessagePayload { + native_msg::MessagePayload { + message: wit_message_to_native(payload.message), + } +} + +/// Converts a WIT Message (role, content parts, channel) into native Message. +fn wit_message_to_native(msg: Message) -> native_msg::Message { + native_msg::Message { + schema_version: msg.schema_version, + role: wit_role_to_native(msg.role), + content: msg.content.into_iter().map(wit_content_part_to_native).collect(), + channel: msg.channel.map(wit_channel_to_native), + } +} + +/// Maps WIT Role enum to native Role enum. +fn wit_role_to_native(role: Role) -> native_enums::Role { + match role { + Role::System => native_enums::Role::System, + Role::Developer => native_enums::Role::Developer, + Role::User => native_enums::Role::User, + Role::Assistant => native_enums::Role::Assistant, + Role::Tool => native_enums::Role::Tool, + } +} + +/// Maps WIT Channel enum to native Channel enum. +fn wit_channel_to_native(channel: Channel) -> native_enums::Channel { + match channel { + Channel::Analysis => native_enums::Channel::Analysis, + Channel::Commentary => native_enums::Channel::Commentary, + Channel::Final => native_enums::Channel::Final, + } +} + +/// Converts each WIT ContentPart variant to its native equivalent. +/// Handles all 12 content types (text, thinking, tool-call, tool-result, etc.). +fn wit_content_part_to_native(part: ContentPart) -> native_content::ContentPart { + match part { + ContentPart::Text(text) => native_content::ContentPart::Text { text }, + ContentPart::Thinking(text) => native_content::ContentPart::Thinking { text }, + ContentPart::ToolCall(tc) => native_content::ContentPart::ToolCall { + content: wit_tool_call_to_native(tc), + }, + ContentPart::ToolResult(tr) => native_content::ContentPart::ToolResult { + content: wit_tool_result_to_native(tr), + }, + ContentPart::CmfResource(r) => native_content::ContentPart::Resource { + content: wit_resource_to_native(r), + }, + ContentPart::ResourceRef(rr) => native_content::ContentPart::ResourceRef { + content: wit_resource_ref_to_native(rr), + }, + ContentPart::PromptRequest(pr) => native_content::ContentPart::PromptRequest { + content: wit_prompt_request_to_native(pr), + }, + ContentPart::PromptResult(pr) => native_content::ContentPart::PromptResult { + content: wit_prompt_result_to_native(pr), + }, + ContentPart::Image(img) => native_content::ContentPart::Image { + content: native_content::ImageSource { + source_type: img.source_type, + data: img.data, + media_type: img.media_type, + }, + }, + ContentPart::Video(v) => native_content::ContentPart::Video { + content: native_content::VideoSource { + source_type: v.source_type, + data: v.data, + media_type: v.media_type, + duration_ms: v.duration_ms, + }, + }, + ContentPart::Audio(a) => native_content::ContentPart::Audio { + content: native_content::AudioSource { + source_type: a.source_type, + data: a.data, + media_type: a.media_type, + duration_ms: a.duration_ms, + }, + }, + ContentPart::Document(d) => native_content::ContentPart::Document { + content: native_content::DocumentSource { + source_type: d.source_type, + data: d.data, + media_type: d.media_type, + title: d.title, + }, + }, + } +} + +/// Deserializes the JSON arguments string into a HashMap for the native ToolCall. +fn wit_tool_call_to_native(tc: ToolCall) -> native_content::ToolCall { + let arguments: HashMap = + serde_json::from_str(&tc.arguments).unwrap_or_default(); + native_content::ToolCall { + tool_call_id: tc.tool_call_id, + name: tc.name, + arguments, + namespace: tc.namespace, + } +} + +/// Deserializes the JSON content string into a serde_json::Value for the native ToolResult. +fn wit_tool_result_to_native(tr: ToolResult) -> native_content::ToolResult { + let content: serde_json::Value = + serde_json::from_str(&tr.content).unwrap_or(serde_json::Value::String(tr.content.clone())); + native_content::ToolResult { + tool_call_id: tr.tool_call_id, + tool_name: tr.tool_name, + content, + is_error: tr.is_error, + } +} + +/// Converts a WIT CmfResource to native Resource, deserializing the annotations JSON string. +fn wit_resource_to_native(r: CmfResource) -> native_content::Resource { + let annotations: HashMap = + serde_json::from_str(&r.annotations).unwrap_or_default(); + native_content::Resource { + resource_request_id: r.resource_request_id, + uri: r.uri, + name: r.name, + description: r.description, + resource_type: wit_resource_type_to_native(r.resource_type), + content: r.content, + blob: r.blob, + mime_type: r.mime_type, + size_bytes: r.size_bytes, + annotations, + version: r.version, + } +} + +/// Maps WIT ResourceType enum to native ResourceType enum. +fn wit_resource_type_to_native(rt: ResourceType) -> native_enums::ResourceType { + match rt { + ResourceType::File => native_enums::ResourceType::File, + ResourceType::Blob => native_enums::ResourceType::Blob, + ResourceType::Uri => native_enums::ResourceType::Uri, + ResourceType::Database => native_enums::ResourceType::Database, + ResourceType::Api => native_enums::ResourceType::Api, + ResourceType::Memory => native_enums::ResourceType::Memory, + ResourceType::Artifact => native_enums::ResourceType::Artifact, + } +} + +/// Converts a WIT ResourceReference to native ResourceReference. +fn wit_resource_ref_to_native(rr: ResourceReference) -> native_content::ResourceReference { + native_content::ResourceReference { + resource_request_id: rr.resource_request_id, + uri: rr.uri, + name: rr.name, + resource_type: wit_resource_type_to_native(rr.resource_type), + range_start: rr.range_start, + range_end: rr.range_end, + selector: rr.selector, + } +} + +/// Converts a WIT PromptRequest to native, deserializing the arguments JSON string. +fn wit_prompt_request_to_native(pr: PromptRequest) -> native_content::PromptRequest { + let arguments: HashMap = + serde_json::from_str(&pr.arguments).unwrap_or_default(); + native_content::PromptRequest { + prompt_request_id: pr.prompt_request_id, + name: pr.name, + arguments, + server_id: pr.server_id, + } +} + +/// Converts a WIT PromptResult to native, deserializing the messages JSON string into Vec. +fn wit_prompt_result_to_native(pr: PromptResult) -> native_content::PromptResult { + let messages: Vec = + serde_json::from_str(&pr.messages).unwrap_or_default(); + native_content::PromptResult { + prompt_request_id: pr.prompt_request_id, + prompt_name: pr.prompt_name, + messages, + content: pr.content, + is_error: pr.is_error, + error_message: pr.error_message, + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: Extensions +// --------------------------------------------------------------------------- + +/// Converts WIT Extensions to native Extensions, wrapping each sub-extension in Arc. +pub fn wit_extensions_to_native(ext: Extensions) -> NativeExtensions { + NativeExtensions { + request: ext.request.map(|r| Arc::new(wit_request_to_native(r))), + security: ext.security.map(|s| Arc::new(wit_security_to_native(s))), + http: ext.http.map(|h| Arc::new(wit_http_to_native(h))), + meta: ext.meta.map(|m| Arc::new(wit_meta_to_native(m))), + ..Default::default() + } +} + +/// Converts WIT RequestExtension (environment, request-id, timestamps, tracing) to native. +fn wit_request_to_native(r: RequestExtension) -> NativeRequestExtension { + NativeRequestExtension { + environment: r.environment, + request_id: r.request_id, + timestamp: r.timestamp, + trace_id: r.trace_id, + span_id: r.span_id, + } +} + +/// Converts WIT SecurityExtension to native, rebuilding MonotonicSet from the labels list. +fn wit_security_to_native(s: SecurityExtension) -> NativeSecurityExtension { + NativeSecurityExtension { + labels: MonotonicSet::from_set(s.labels.into_iter().collect()), + classification: s.classification, + subject: s.subject.map(wit_subject_to_native), + auth_method: s.auth_method, + ..Default::default() + } +} + +/// Converts WIT SubjectExtension to native, collecting lists into HashSets/HashMaps. +fn wit_subject_to_native(s: SubjectExtension) -> NativeSubjectExtension { + NativeSubjectExtension { + id: s.id, + subject_type: s.subject_type.map(wit_subject_type_to_native), + roles: s.roles.into_iter().collect::>(), + permissions: s.permissions.into_iter().collect::>(), + teams: s.teams.into_iter().collect::>(), + claims: s.claims.into_iter().collect::>(), + } +} + +/// Maps WIT SubjectType enum to native SubjectType enum. +fn wit_subject_type_to_native(st: SubjectType) -> NativeSubjectType { + match st { + SubjectType::User => NativeSubjectType::User, + SubjectType::Agent => NativeSubjectType::Agent, + SubjectType::Service => NativeSubjectType::Service, + SubjectType::System => NativeSubjectType::System, + } +} + +/// Converts WIT HttpExtension (list of tuples) to native (HashMap-based headers). +fn wit_http_to_native(h: HttpExtension) -> NativeHttpExtension { + NativeHttpExtension { + request_headers: h.request_headers.into_iter().collect::>(), + response_headers: h.response_headers.into_iter().collect::>(), + } +} + +/// Converts WIT MetaExtension to native, collecting lists into HashSets/HashMaps. +fn wit_meta_to_native(m: MetaExtension) -> NativeMetaExtension { + NativeMetaExtension { + entity_type: m.entity_type, + entity_name: m.entity_name, + tags: m.tags.into_iter().collect::>(), + scope: m.scope, + properties: m.properties.into_iter().collect::>(), + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: PluginContext +// --------------------------------------------------------------------------- + +/// Deserializes the JSON state strings into HashMaps for the native PluginContext. +pub fn wit_context_to_native(ctx: PluginContext) -> NativePluginContext { + let local_state = serde_json::from_str(&ctx.local_state).unwrap_or_default(); + let global_state = serde_json::from_str(&ctx.global_state).unwrap_or_default(); + NativePluginContext { + local_state, + global_state, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: PluginResult → PluginResult (record) +// --------------------------------------------------------------------------- + +/// Converts the native PluginResult back to WIT, including any modified payload, +/// modified extensions, violation, or metadata. +pub fn native_result_to_wit(result: NativePluginResult) -> PluginResult { + PluginResult { + continue_processing: result.continue_processing, + modified_payload: result.modified_payload.map(native_payload_to_wit), + modified_extensions: result.modified_extensions.map(|ext| native_owned_extensions_to_wit(&ext)), + violation: result.violation.map(native_violation_to_wit), + metadata: result.metadata.map(|v| serde_json::to_string(&v).unwrap_or_default()), + } +} + +/// Converts a native PluginViolation to WIT, serializing details HashMap to JSON string. +fn native_violation_to_wit(v: NativePluginViolation) -> PluginViolation { + PluginViolation { + code: v.code, + reason: v.reason, + description: v.description, + details: serde_json::to_string(&v.details).unwrap_or_else(|_| "{}".to_string()), + proto_error_code: v.proto_error_code, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: MessagePayload +// --------------------------------------------------------------------------- + +/// Converts a native MessagePayload back to WIT format for the return value. +fn native_payload_to_wit(payload: native_msg::MessagePayload) -> MessagePayload { + MessagePayload { + message: native_message_to_wit(payload.message), + } +} + +/// Converts a native Message back to WIT Message. +fn native_message_to_wit(msg: native_msg::Message) -> Message { + Message { + schema_version: msg.schema_version, + role: native_role_to_wit(msg.role), + content: msg.content.into_iter().map(native_content_part_to_wit).collect(), + channel: msg.channel.map(native_channel_to_wit), + } +} + +/// Maps native Role enum to WIT Role enum. +fn native_role_to_wit(role: native_enums::Role) -> Role { + match role { + native_enums::Role::System => Role::System, + native_enums::Role::Developer => Role::Developer, + native_enums::Role::User => Role::User, + native_enums::Role::Assistant => Role::Assistant, + native_enums::Role::Tool => Role::Tool, + } +} + +/// Maps native Channel enum to WIT Channel enum. +fn native_channel_to_wit(channel: native_enums::Channel) -> Channel { + match channel { + native_enums::Channel::Analysis => Channel::Analysis, + native_enums::Channel::Commentary => Channel::Commentary, + native_enums::Channel::Final => Channel::Final, + } +} + +/// Converts each native ContentPart variant back to its WIT equivalent. +fn native_content_part_to_wit(part: native_content::ContentPart) -> ContentPart { + match part { + native_content::ContentPart::Text { text } => ContentPart::Text(text), + native_content::ContentPart::Thinking { text } => ContentPart::Thinking(text), + native_content::ContentPart::ToolCall { content } => { + ContentPart::ToolCall(native_tool_call_to_wit(content)) + } + native_content::ContentPart::ToolResult { content } => { + ContentPart::ToolResult(native_tool_result_to_wit(content)) + } + native_content::ContentPart::Resource { content } => { + ContentPart::CmfResource(native_resource_to_wit(content)) + } + native_content::ContentPart::ResourceRef { content } => { + ContentPart::ResourceRef(native_resource_ref_to_wit(content)) + } + native_content::ContentPart::PromptRequest { content } => { + ContentPart::PromptRequest(native_prompt_request_to_wit(content)) + } + native_content::ContentPart::PromptResult { content } => { + ContentPart::PromptResult(native_prompt_result_to_wit(content)) + } + native_content::ContentPart::Image { content } => ContentPart::Image(ImageSource { + source_type: content.source_type, + data: content.data, + media_type: content.media_type, + }), + native_content::ContentPart::Video { content } => ContentPart::Video(VideoSource { + source_type: content.source_type, + data: content.data, + media_type: content.media_type, + duration_ms: content.duration_ms, + }), + native_content::ContentPart::Audio { content } => ContentPart::Audio(AudioSource { + source_type: content.source_type, + data: content.data, + media_type: content.media_type, + duration_ms: content.duration_ms, + }), + native_content::ContentPart::Document { content } => { + ContentPart::Document(DocumentSource { + source_type: content.source_type, + data: content.data, + media_type: content.media_type, + title: content.title, + }) + } + } +} + +/// Serializes the native ToolCall arguments HashMap back to a JSON string for WIT. +fn native_tool_call_to_wit(tc: native_content::ToolCall) -> ToolCall { + ToolCall { + tool_call_id: tc.tool_call_id, + name: tc.name, + arguments: serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string()), + namespace: tc.namespace, + } +} + +/// Serializes the native ToolResult content Value back to a JSON string for WIT. +fn native_tool_result_to_wit(tr: native_content::ToolResult) -> ToolResult { + ToolResult { + tool_call_id: tr.tool_call_id, + tool_name: tr.tool_name, + content: serde_json::to_string(&tr.content).unwrap_or_default(), + is_error: tr.is_error, + } +} + +/// Converts native Resource to WIT CmfResource, serializing annotations to JSON string. +fn native_resource_to_wit(r: native_content::Resource) -> CmfResource { + CmfResource { + resource_request_id: r.resource_request_id, + uri: r.uri, + name: r.name, + description: r.description, + resource_type: native_resource_type_to_wit(r.resource_type), + content: r.content, + blob: r.blob, + mime_type: r.mime_type, + size_bytes: r.size_bytes, + annotations: serde_json::to_string(&r.annotations).unwrap_or_else(|_| "{}".to_string()), + version: r.version, + } +} + +/// Maps native ResourceType enum to WIT ResourceType enum. +fn native_resource_type_to_wit(rt: native_enums::ResourceType) -> ResourceType { + match rt { + native_enums::ResourceType::File => ResourceType::File, + native_enums::ResourceType::Blob => ResourceType::Blob, + native_enums::ResourceType::Uri => ResourceType::Uri, + native_enums::ResourceType::Database => ResourceType::Database, + native_enums::ResourceType::Api => ResourceType::Api, + native_enums::ResourceType::Memory => ResourceType::Memory, + native_enums::ResourceType::Artifact => ResourceType::Artifact, + } +} + +/// Converts native ResourceReference to WIT ResourceReference. +fn native_resource_ref_to_wit(rr: native_content::ResourceReference) -> ResourceReference { + ResourceReference { + resource_request_id: rr.resource_request_id, + uri: rr.uri, + name: rr.name, + resource_type: native_resource_type_to_wit(rr.resource_type), + range_start: rr.range_start, + range_end: rr.range_end, + selector: rr.selector, + } +} + +/// Converts native PromptRequest to WIT, serializing arguments HashMap to JSON string. +fn native_prompt_request_to_wit(pr: native_content::PromptRequest) -> PromptRequest { + PromptRequest { + prompt_request_id: pr.prompt_request_id, + name: pr.name, + arguments: serde_json::to_string(&pr.arguments).unwrap_or_else(|_| "{}".to_string()), + server_id: pr.server_id, + } +} + +/// Converts native PromptResult to WIT, serializing messages Vec to JSON string. +fn native_prompt_result_to_wit(pr: native_content::PromptResult) -> PromptResult { + PromptResult { + prompt_request_id: pr.prompt_request_id, + prompt_name: pr.prompt_name, + messages: serde_json::to_string(&pr.messages).unwrap_or_else(|_| "[]".to_string()), + content: pr.content, + is_error: pr.is_error, + error_message: pr.error_message, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: OwnedExtensions → Extensions +// --------------------------------------------------------------------------- + +/// Converts native OwnedExtensions (used in modified results) back to WIT Extensions. +fn native_owned_extensions_to_wit( + ext: &cpex_payload::extensions::container::OwnedExtensions, +) -> Extensions { + Extensions { + request: ext.request.as_ref().map(|r| native_request_to_wit(r)), + security: ext.security.as_ref().map(|s| native_security_to_wit(s)), + http: ext.http.as_ref().map(|h| native_http_to_wit(h.read())), + meta: ext.meta.as_ref().map(|m| native_meta_to_wit(m)), + } +} + +/// Converts native RequestExtension to WIT RequestExtension. +fn native_request_to_wit(r: &NativeRequestExtension) -> RequestExtension { + RequestExtension { + environment: r.environment.clone(), + request_id: r.request_id.clone(), + timestamp: r.timestamp.clone(), + trace_id: r.trace_id.clone(), + span_id: r.span_id.clone(), + } +} + +/// Converts native SecurityExtension to WIT, collecting MonotonicSet labels into a list. +fn native_security_to_wit(s: &NativeSecurityExtension) -> SecurityExtension { + SecurityExtension { + labels: s.labels.iter().cloned().collect(), + classification: s.classification.clone(), + subject: s.subject.as_ref().map(native_subject_to_wit), + auth_method: s.auth_method.clone(), + } +} + +/// Converts native SubjectExtension to WIT, collecting HashSets into lists. +fn native_subject_to_wit(s: &NativeSubjectExtension) -> SubjectExtension { + SubjectExtension { + id: s.id.clone(), + subject_type: s.subject_type.as_ref().map(native_subject_type_to_wit), + roles: s.roles.iter().cloned().collect(), + permissions: s.permissions.iter().cloned().collect(), + teams: s.teams.iter().cloned().collect(), + claims: s.claims.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + } +} + +/// Maps native SubjectType enum to WIT SubjectType enum. +fn native_subject_type_to_wit(st: &NativeSubjectType) -> SubjectType { + match st { + NativeSubjectType::User => SubjectType::User, + NativeSubjectType::Agent => SubjectType::Agent, + NativeSubjectType::Service => SubjectType::Service, + NativeSubjectType::System => SubjectType::System, + } +} + +/// Converts native HttpExtension (HashMaps) to WIT (list of tuples). +fn native_http_to_wit(h: &NativeHttpExtension) -> HttpExtension { + HttpExtension { + request_headers: h.request_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + response_headers: h.response_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + } +} + +/// Converts native MetaExtension (HashSets/HashMaps) to WIT (lists of strings/tuples). +fn native_meta_to_wit(m: &NativeMetaExtension) -> MetaExtension { + MetaExtension { + entity_type: m.entity_type.clone(), + entity_name: m.entity_name.clone(), + tags: m.tags.iter().cloned().collect(), + scope: m.scope.clone(), + properties: m.properties.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + } +} diff --git a/crates/cpex-wasm-plugin/src/lib.rs b/crates/cpex-wasm-plugin/src/lib.rs new file mode 100644 index 00000000..5bab38d7 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/lib.rs @@ -0,0 +1,78 @@ +// Location: ./crates/cpex-wasm-plugin/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// CPEX WASM Plugin — Entry point for the WebAssembly plugin component. +// +// Implements the Guest trait generated by wit-bindgen, which the WASM host +// calls via the exported `handle-hook` function. Converts between WIT types +// and native cpex-payload types, delegating to the actual plugin logic. + +mod conversions; + +// Generate Rust bindings from the WIT world definition. +// This produces the `Guest` trait and all WIT types (MessagePayload, Extensions, etc.) +// that the plugin must implement and use. +wit_bindgen::generate!({ + path: "wit", + world: "plugin", + generate_all, +}); + +use conversions::{native_result_to_wit, wit_context_to_native, wit_extensions_to_native, wit_payload_to_native}; + +// --------------------------------------------------------------------------- +// Plugin Template +// +// To use a different plugin, change the function call below to point to your +// plugin function in cpex-payload. All plugin functions share the same signature: +// +// fn(&MessagePayload, &Extensions, &PluginContext) -> PluginResult +// +// Example: to use the audit logger instead, replace the identity_check call with: +// cpex_payload::plugins::audit_logger::audit_log(&native_payload, &native_extensions, &native_ctx) +// --------------------------------------------------------------------------- + +struct Plugin; + +impl Guest for Plugin { + /// Entry point called by the WASM host for each hook invocation. + /// Converts WIT types to native Rust types, delegates to the plugin + /// logic in cpex-payload, and converts the result back to WIT. + fn handle_hook( + payload: MessagePayload, + extensions: Extensions, + ctx: PluginContext, + ) -> PluginResult { + eprintln!("[WASM] handle_hook called"); + eprintln!("[WASM] payload content parts {:?} : with length {}",payload.message.content, payload.message.content.len()); + + // Convert the WIT MessagePayload to native cpex-payload MessagePayload + let native_payload = wit_payload_to_native(payload); + eprintln!("[WASM] payload converted to native={:?}",native_payload); + + // Convert the WIT Extensions to native Extensions (wrapped in Arc) + let native_extensions = wit_extensions_to_native(extensions); + eprintln!("[WASM] extensions converted to native={:?}",native_extensions); + + // Convert the WIT PluginContext JSON strings to native HashMap-based context + let native_ctx = wit_context_to_native(ctx); + eprintln!("[WASM] context converted to native={:?}",native_ctx); + + // Call the plugin logic — swap this line to use a different plugin + eprintln!("[WASM] calling identity_check..."); + let result = cpex_payload::plugins::identity_checker::identity_check( + &native_payload, // the CMF message being processed + &native_extensions, // security, HTTP, meta, request extensions + &native_ctx, // plugin-local and global shared state + ); + eprintln!("[WASM] identity_check returned: continue_processing={}", result.continue_processing); + + // Convert the native PluginResult back to WIT for the host to consume + native_result_to_wit(result) + } +} + +// Registers the Plugin struct as the WASM component's exported implementation of the Guest trait +export!(Plugin); diff --git a/crates/cpex-wasm-plugin/wit/deps/cli.wit b/crates/cpex-wasm-plugin/wit/deps/cli.wit new file mode 100644 index 00000000..d7a3ca4d --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/cli.wit @@ -0,0 +1,261 @@ +package wasi:cli@0.2.6; + +@since(version = 0.2.0) +interface environment { + /// Get the POSIX-style environment variables. + /// + /// Each environment variable is provided as a pair of string variable names + /// and string value. + /// + /// Morally, these are a value import, but until value imports are available + /// in the component model, this import function should return the same + /// values each time it is called. + @since(version = 0.2.0) + get-environment: func() -> list>; + + /// Get the POSIX-style arguments to the program. + @since(version = 0.2.0) + get-arguments: func() -> list; + + /// Return a path that programs should use as their initial current working + /// directory, interpreting `.` as shorthand for this. + @since(version = 0.2.0) + initial-cwd: func() -> option; +} + +@since(version = 0.2.0) +interface exit { + /// Exit the current instance and any linked instances. + @since(version = 0.2.0) + exit: func(status: result); + + /// Exit the current instance and any linked instances, reporting the + /// specified status code to the host. + /// + /// The meaning of the code depends on the context, with 0 usually meaning + /// "success", and other values indicating various types of failure. + /// + /// This function does not return; the effect is analogous to a trap, but + /// without the connotation that something bad has happened. + @unstable(feature = cli-exit-with-code) + exit-with-code: func(status-code: u8); +} + +@since(version = 0.2.0) +interface run { + /// Run the program. + @since(version = 0.2.0) + run: func() -> result; +} + +@since(version = 0.2.0) +interface stdin { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream}; + + @since(version = 0.2.0) + get-stdin: func() -> input-stream; +} + +@since(version = 0.2.0) +interface stdout { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{output-stream}; + + @since(version = 0.2.0) + get-stdout: func() -> output-stream; +} + +@since(version = 0.2.0) +interface stderr { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{output-stream}; + + @since(version = 0.2.0) + get-stderr: func() -> output-stream; +} + +/// Terminal input. +/// +/// In the future, this may include functions for disabling echoing, +/// disabling input buffering so that keyboard events are sent through +/// immediately, querying supported features, and so on. +@since(version = 0.2.0) +interface terminal-input { + /// The input side of a terminal. + @since(version = 0.2.0) + resource terminal-input; +} + +/// Terminal output. +/// +/// In the future, this may include functions for querying the terminal +/// size, being notified of terminal size changes, querying supported +/// features, and so on. +@since(version = 0.2.0) +interface terminal-output { + /// The output side of a terminal. + @since(version = 0.2.0) + resource terminal-output; +} + +/// An interface providing an optional `terminal-input` for stdin as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdin { + @since(version = 0.2.0) + use terminal-input.{terminal-input}; + + /// If stdin is connected to a terminal, return a `terminal-input` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdin: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stdout as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdout { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stdout is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdout: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stderr as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stderr { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stderr is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stderr: func() -> option; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.6; +} +@since(version = 0.2.0) +world command { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.6; + + @since(version = 0.2.0) + export run; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/clocks.wit b/crates/cpex-wasm-plugin/wit/deps/clocks.wit new file mode 100644 index 00000000..d638f1a4 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/clocks.wit @@ -0,0 +1,157 @@ +package wasi:clocks@0.2.6; + +/// WASI Monotonic Clock is a clock API intended to let users measure elapsed +/// time. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A monotonic clock is a clock which has an unspecified initial value, and +/// successive reads of the clock will produce non-decreasing values. +@since(version = 0.2.0) +interface monotonic-clock { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + + /// An instant in time, in nanoseconds. An instant is relative to an + /// unspecified initial value, and can only be compared to instances from + /// the same monotonic-clock. + @since(version = 0.2.0) + type instant = u64; + + /// A duration of time, in nanoseconds. + @since(version = 0.2.0) + type duration = u64; + + /// Read the current value of the clock. + /// + /// The clock is monotonic, therefore calling this function repeatedly will + /// produce a sequence of non-decreasing values. + @since(version = 0.2.0) + now: func() -> instant; + + /// Query the resolution of the clock. Returns the duration of time + /// corresponding to a clock tick. + @since(version = 0.2.0) + resolution: func() -> duration; + + /// Create a `pollable` which will resolve once the specified instant + /// has occurred. + @since(version = 0.2.0) + subscribe-instant: func(when: instant) -> pollable; + + /// Create a `pollable` that will resolve after the specified duration has + /// elapsed from the time this function is invoked. + @since(version = 0.2.0) + subscribe-duration: func(when: duration) -> pollable; +} + +/// WASI Wall Clock is a clock API intended to let users query the current +/// time. The name "wall" makes an analogy to a "clock on the wall", which +/// is not necessarily monotonic as it may be reset. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A wall clock is a clock which measures the date and time according to +/// some external reference. +/// +/// External references may be reset, so this clock is not necessarily +/// monotonic, making it unsuitable for measuring elapsed time. +/// +/// It is intended for reporting the current date and time for humans. +@since(version = 0.2.0) +interface wall-clock { + /// A time and date in seconds plus nanoseconds. + @since(version = 0.2.0) + record datetime { + seconds: u64, + nanoseconds: u32, + } + + /// Read the current value of the clock. + /// + /// This clock is not monotonic, therefore calling this function repeatedly + /// will not necessarily produce a sequence of non-decreasing values. + /// + /// The returned timestamps represent the number of seconds since + /// 1970-01-01T00:00:00Z, also known as [POSIX's Seconds Since the Epoch], + /// also known as [Unix Time]. + /// + /// The nanoseconds field of the output is always less than 1000000000. + /// + /// [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 + /// [Unix Time]: https://en.wikipedia.org/wiki/Unix_time + @since(version = 0.2.0) + now: func() -> datetime; + + /// Query the resolution of the clock. + /// + /// The nanoseconds field of the output is always less than 1000000000. + @since(version = 0.2.0) + resolution: func() -> datetime; +} + +@unstable(feature = clocks-timezone) +interface timezone { + @unstable(feature = clocks-timezone) + use wall-clock.{datetime}; + + /// Information useful for displaying the timezone of a specific `datetime`. + /// + /// This information may vary within a single `timezone` to reflect daylight + /// saving time adjustments. + @unstable(feature = clocks-timezone) + record timezone-display { + /// The number of seconds difference between UTC time and the local + /// time of the timezone. + /// + /// The returned value will always be less than 86400 which is the + /// number of seconds in a day (24*60*60). + /// + /// In implementations that do not expose an actual time zone, this + /// should return 0. + utc-offset: s32, + /// The abbreviated name of the timezone to display to a user. The name + /// `UTC` indicates Coordinated Universal Time. Otherwise, this should + /// reference local standards for the name of the time zone. + /// + /// In implementations that do not expose an actual time zone, this + /// should be the string `UTC`. + /// + /// In time zones that do not have an applicable name, a formatted + /// representation of the UTC offset may be returned, such as `-04:00`. + name: string, + /// Whether daylight saving time is active. + /// + /// In implementations that do not expose an actual time zone, this + /// should return false. + in-daylight-saving-time: bool, + } + + /// Return information needed to display the given `datetime`. This includes + /// the UTC offset, the time zone name, and a flag indicating whether + /// daylight saving time is active. + /// + /// If the timezone cannot be determined for the given `datetime`, return a + /// `timezone-display` for `UTC` with a `utc-offset` of 0 and no daylight + /// saving time. + @unstable(feature = clocks-timezone) + display: func(when: datetime) -> timezone-display; + + /// The same as `display`, but only return the UTC offset. + @unstable(feature = clocks-timezone) + utc-offset: func(when: datetime) -> s32; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import monotonic-clock; + @since(version = 0.2.0) + import wall-clock; + @unstable(feature = clocks-timezone) + import timezone; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/filesystem.wit b/crates/cpex-wasm-plugin/wit/deps/filesystem.wit new file mode 100644 index 00000000..9f4a8288 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/filesystem.wit @@ -0,0 +1,587 @@ +package wasi:filesystem@0.2.6; + +/// WASI filesystem is a filesystem API primarily intended to let users run WASI +/// programs that access their files on their existing filesystems, without +/// significant overhead. +/// +/// It is intended to be roughly portable between Unix-family platforms and +/// Windows, though it does not hide many of the major differences. +/// +/// Paths are passed as interface-type `string`s, meaning they must consist of +/// a sequence of Unicode Scalar Values (USVs). Some filesystems may contain +/// paths which are not accessible by this API. +/// +/// The directory separator in WASI is always the forward-slash (`/`). +/// +/// All paths in WASI are relative paths, and are interpreted relative to a +/// `descriptor` referring to a base directory. If a `path` argument to any WASI +/// function starts with `/`, or if any step of resolving a `path`, including +/// `..` and symbolic link steps, reaches a directory outside of the base +/// directory, or reaches a symlink to an absolute or rooted path in the +/// underlying filesystem, the function fails with `error-code::not-permitted`. +/// +/// For more information about WASI path resolution and sandboxing, see +/// [WASI filesystem path resolution]. +/// +/// [WASI filesystem path resolution]: https://github.com/WebAssembly/wasi-filesystem/blob/main/path-resolution.md +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream, error}; + @since(version = 0.2.0) + use wasi:clocks/wall-clock@0.2.6.{datetime}; + + /// File size or length of a region within a file. + @since(version = 0.2.0) + type filesize = u64; + + /// The type of a filesystem object referenced by a descriptor. + /// + /// Note: This was called `filetype` in earlier versions of WASI. + @since(version = 0.2.0) + enum descriptor-type { + /// The type of the descriptor or file is unknown or is different from + /// any of the other types specified. + unknown, + /// The descriptor refers to a block device inode. + block-device, + /// The descriptor refers to a character device inode. + character-device, + /// The descriptor refers to a directory inode. + directory, + /// The descriptor refers to a named pipe. + fifo, + /// The file refers to a symbolic link inode. + symbolic-link, + /// The descriptor refers to a regular file inode. + regular-file, + /// The descriptor refers to a socket. + socket, + } + + /// Descriptor flags. + /// + /// Note: This was called `fdflags` in earlier versions of WASI. + @since(version = 0.2.0) + flags descriptor-flags { + /// Read mode: Data can be read. + read, + /// Write mode: Data can be written to. + write, + /// Request that writes be performed according to synchronized I/O file + /// integrity completion. The data stored in the file and the file's + /// metadata are synchronized. This is similar to `O_SYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + file-integrity-sync, + /// Request that writes be performed according to synchronized I/O data + /// integrity completion. Only the data stored in the file is + /// synchronized. This is similar to `O_DSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + data-integrity-sync, + /// Requests that reads be performed at the same level of integrity + /// requested for writes. This is similar to `O_RSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + requested-write-sync, + /// Mutating directories mode: Directory contents may be mutated. + /// + /// When this flag is unset on a descriptor, operations using the + /// descriptor which would create, rename, delete, modify the data or + /// metadata of filesystem objects, or obtain another handle which + /// would permit any of those, shall fail with `error-code::read-only` if + /// they would otherwise succeed. + /// + /// This may only be set on directories. + mutate-directory, + } + + /// Flags determining the method of how paths are resolved. + @since(version = 0.2.0) + flags path-flags { + /// As long as the resolved path corresponds to a symbolic link, it is + /// expanded. + symlink-follow, + } + + /// Open flags used by `open-at`. + @since(version = 0.2.0) + flags open-flags { + /// Create file if it does not exist, similar to `O_CREAT` in POSIX. + create, + /// Fail if not a directory, similar to `O_DIRECTORY` in POSIX. + directory, + /// Fail if file already exists, similar to `O_EXCL` in POSIX. + exclusive, + /// Truncate file to size 0, similar to `O_TRUNC` in POSIX. + truncate, + } + + /// Number of hard links to an inode. + @since(version = 0.2.0) + type link-count = u64; + + /// File attributes. + /// + /// Note: This was called `filestat` in earlier versions of WASI. + @since(version = 0.2.0) + record descriptor-stat { + /// File type. + %type: descriptor-type, + /// Number of hard links to the file. + link-count: link-count, + /// For regular files, the file size in bytes. For symbolic links, the + /// length in bytes of the pathname contained in the symbolic link. + size: filesize, + /// Last data access timestamp. + /// + /// If the `option` is none, the platform doesn't maintain an access + /// timestamp for this file. + data-access-timestamp: option, + /// Last data modification timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// modification timestamp for this file. + data-modification-timestamp: option, + /// Last file status-change timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// status-change timestamp for this file. + status-change-timestamp: option, + } + + /// When setting a timestamp, this gives the value to set it to. + @since(version = 0.2.0) + variant new-timestamp { + /// Leave the timestamp set to its previous value. + no-change, + /// Set the timestamp to the current time of the system clock associated + /// with the filesystem. + now, + /// Set the timestamp to the given value. + timestamp(datetime), + } + + /// A directory entry. + record directory-entry { + /// The type of the file referred to by this directory entry. + %type: descriptor-type, + /// The name of the object. + name: string, + } + + /// Error codes returned by functions, similar to `errno` in POSIX. + /// Not all of these error codes are returned by the functions provided by this + /// API; some are used in higher-level library layers, and others are provided + /// merely for alignment with POSIX. + enum error-code { + /// Permission denied, similar to `EACCES` in POSIX. + access, + /// Resource unavailable, or operation would block, similar to `EAGAIN` and `EWOULDBLOCK` in POSIX. + would-block, + /// Connection already in progress, similar to `EALREADY` in POSIX. + already, + /// Bad descriptor, similar to `EBADF` in POSIX. + bad-descriptor, + /// Device or resource busy, similar to `EBUSY` in POSIX. + busy, + /// Resource deadlock would occur, similar to `EDEADLK` in POSIX. + deadlock, + /// Storage quota exceeded, similar to `EDQUOT` in POSIX. + quota, + /// File exists, similar to `EEXIST` in POSIX. + exist, + /// File too large, similar to `EFBIG` in POSIX. + file-too-large, + /// Illegal byte sequence, similar to `EILSEQ` in POSIX. + illegal-byte-sequence, + /// Operation in progress, similar to `EINPROGRESS` in POSIX. + in-progress, + /// Interrupted function, similar to `EINTR` in POSIX. + interrupted, + /// Invalid argument, similar to `EINVAL` in POSIX. + invalid, + /// I/O error, similar to `EIO` in POSIX. + io, + /// Is a directory, similar to `EISDIR` in POSIX. + is-directory, + /// Too many levels of symbolic links, similar to `ELOOP` in POSIX. + loop, + /// Too many links, similar to `EMLINK` in POSIX. + too-many-links, + /// Message too large, similar to `EMSGSIZE` in POSIX. + message-size, + /// Filename too long, similar to `ENAMETOOLONG` in POSIX. + name-too-long, + /// No such device, similar to `ENODEV` in POSIX. + no-device, + /// No such file or directory, similar to `ENOENT` in POSIX. + no-entry, + /// No locks available, similar to `ENOLCK` in POSIX. + no-lock, + /// Not enough space, similar to `ENOMEM` in POSIX. + insufficient-memory, + /// No space left on device, similar to `ENOSPC` in POSIX. + insufficient-space, + /// Not a directory or a symbolic link to a directory, similar to `ENOTDIR` in POSIX. + not-directory, + /// Directory not empty, similar to `ENOTEMPTY` in POSIX. + not-empty, + /// State not recoverable, similar to `ENOTRECOVERABLE` in POSIX. + not-recoverable, + /// Not supported, similar to `ENOTSUP` and `ENOSYS` in POSIX. + unsupported, + /// Inappropriate I/O control operation, similar to `ENOTTY` in POSIX. + no-tty, + /// No such device or address, similar to `ENXIO` in POSIX. + no-such-device, + /// Value too large to be stored in data type, similar to `EOVERFLOW` in POSIX. + overflow, + /// Operation not permitted, similar to `EPERM` in POSIX. + not-permitted, + /// Broken pipe, similar to `EPIPE` in POSIX. + pipe, + /// Read-only file system, similar to `EROFS` in POSIX. + read-only, + /// Invalid seek, similar to `ESPIPE` in POSIX. + invalid-seek, + /// Text file busy, similar to `ETXTBSY` in POSIX. + text-file-busy, + /// Cross-device link, similar to `EXDEV` in POSIX. + cross-device, + } + + /// File or memory access pattern advisory information. + @since(version = 0.2.0) + enum advice { + /// The application has no advice to give on its behavior with respect + /// to the specified data. + normal, + /// The application expects to access the specified data sequentially + /// from lower offsets to higher offsets. + sequential, + /// The application expects to access the specified data in a random + /// order. + random, + /// The application expects to access the specified data in the near + /// future. + will-need, + /// The application expects that it will not access the specified data + /// in the near future. + dont-need, + /// The application expects to access the specified data once and then + /// not reuse it thereafter. + no-reuse, + } + + /// A 128-bit hash value, split into parts because wasm doesn't have a + /// 128-bit integer type. + @since(version = 0.2.0) + record metadata-hash-value { + /// 64 bits of a 128-bit hash value. + lower: u64, + /// Another 64 bits of a 128-bit hash value. + upper: u64, + } + + /// A descriptor is a reference to a filesystem object, which may be a file, + /// directory, named pipe, special file, or other object on which filesystem + /// calls may be made. + @since(version = 0.2.0) + resource descriptor { + /// Return a stream for reading from a file, if available. + /// + /// May fail with an error-code describing why the file cannot be read. + /// + /// Multiple read, write, and append streams may be active on the same open + /// file and they do not interfere with each other. + /// + /// Note: This allows using `read-stream`, which is similar to `read` in POSIX. + @since(version = 0.2.0) + read-via-stream: func(offset: filesize) -> result; + /// Return a stream for writing to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be written. + /// + /// Note: This allows using `write-stream`, which is similar to `write` in + /// POSIX. + @since(version = 0.2.0) + write-via-stream: func(offset: filesize) -> result; + /// Return a stream for appending to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be appended. + /// + /// Note: This allows using `write-stream`, which is similar to `write` with + /// `O_APPEND` in POSIX. + @since(version = 0.2.0) + append-via-stream: func() -> result; + /// Provide file advisory information on a descriptor. + /// + /// This is similar to `posix_fadvise` in POSIX. + @since(version = 0.2.0) + advise: func(offset: filesize, length: filesize, advice: advice) -> result<_, error-code>; + /// Synchronize the data of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fdatasync` in POSIX. + @since(version = 0.2.0) + sync-data: func() -> result<_, error-code>; + /// Get flags associated with a descriptor. + /// + /// Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX. + /// + /// Note: This returns the value that was the `fs_flags` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-flags: func() -> result; + /// Get the dynamic type of a descriptor. + /// + /// Note: This returns the same value as the `type` field of the `fd-stat` + /// returned by `stat`, `stat-at` and similar. + /// + /// Note: This returns similar flags to the `st_mode & S_IFMT` value provided + /// by `fstat` in POSIX. + /// + /// Note: This returns the value that was the `fs_filetype` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-type: func() -> result; + /// Adjust the size of an open file. If this increases the file's size, the + /// extra bytes are filled with zeros. + /// + /// Note: This was called `fd_filestat_set_size` in earlier versions of WASI. + @since(version = 0.2.0) + set-size: func(size: filesize) -> result<_, error-code>; + /// Adjust the timestamps of an open file or directory. + /// + /// Note: This is similar to `futimens` in POSIX. + /// + /// Note: This was called `fd_filestat_set_times` in earlier versions of WASI. + @since(version = 0.2.0) + set-times: func(data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Read from a descriptor, without using and updating the descriptor's offset. + /// + /// This function returns a list of bytes containing the data that was + /// read, along with a bool which, when true, indicates that the end of the + /// file was reached. The returned list will contain up to `length` bytes; it + /// may return fewer than requested, if the end of the file is reached or + /// if the I/O operation is interrupted. + /// + /// In the future, this may change to return a `stream`. + /// + /// Note: This is similar to `pread` in POSIX. + @since(version = 0.2.0) + read: func(length: filesize, offset: filesize) -> result, bool>, error-code>; + /// Write to a descriptor, without using and updating the descriptor's offset. + /// + /// It is valid to write past the end of a file; the file is extended to the + /// extent of the write, with bytes between the previous end and the start of + /// the write set to zero. + /// + /// In the future, this may change to take a `stream`. + /// + /// Note: This is similar to `pwrite` in POSIX. + @since(version = 0.2.0) + write: func(buffer: list, offset: filesize) -> result; + /// Read directory entries from a directory. + /// + /// On filesystems where directories contain entries referring to themselves + /// and their parents, often named `.` and `..` respectively, these entries + /// are omitted. + /// + /// This always returns a new stream which starts at the beginning of the + /// directory. Multiple streams may be active on the same directory, and they + /// do not interfere with each other. + @since(version = 0.2.0) + read-directory: func() -> result; + /// Synchronize the data and metadata of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fsync` in POSIX. + @since(version = 0.2.0) + sync: func() -> result<_, error-code>; + /// Create a directory. + /// + /// Note: This is similar to `mkdirat` in POSIX. + @since(version = 0.2.0) + create-directory-at: func(path: string) -> result<_, error-code>; + /// Return the attributes of an open file or directory. + /// + /// Note: This is similar to `fstat` in POSIX, except that it does not return + /// device and inode information. For testing whether two descriptors refer to + /// the same underlying filesystem object, use `is-same-object`. To obtain + /// additional data that can be used do determine whether a file has been + /// modified, use `metadata-hash`. + /// + /// Note: This was called `fd_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat: func() -> result; + /// Return the attributes of a file or directory. + /// + /// Note: This is similar to `fstatat` in POSIX, except that it does not + /// return device and inode information. See the `stat` description for a + /// discussion of alternatives. + /// + /// Note: This was called `path_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat-at: func(path-flags: path-flags, path: string) -> result; + /// Adjust the timestamps of a file or directory. + /// + /// Note: This is similar to `utimensat` in POSIX. + /// + /// Note: This was called `path_filestat_set_times` in earlier versions of + /// WASI. + @since(version = 0.2.0) + set-times-at: func(path-flags: path-flags, path: string, data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Create a hard link. + /// + /// Fails with `error-code::no-entry` if the old path does not exist, + /// with `error-code::exist` if the new path already exists, and + /// `error-code::not-permitted` if the old path is not a file. + /// + /// Note: This is similar to `linkat` in POSIX. + @since(version = 0.2.0) + link-at: func(old-path-flags: path-flags, old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Open a file or directory. + /// + /// If `flags` contains `descriptor-flags::mutate-directory`, and the base + /// descriptor doesn't have `descriptor-flags::mutate-directory` set, + /// `open-at` fails with `error-code::read-only`. + /// + /// If `flags` contains `write` or `mutate-directory`, or `open-flags` + /// contains `truncate` or `create`, and the base descriptor doesn't have + /// `descriptor-flags::mutate-directory` set, `open-at` fails with + /// `error-code::read-only`. + /// + /// Note: This is similar to `openat` in POSIX. + @since(version = 0.2.0) + open-at: func(path-flags: path-flags, path: string, open-flags: open-flags, %flags: descriptor-flags) -> result; + /// Read the contents of a symbolic link. + /// + /// If the contents contain an absolute or rooted path in the underlying + /// filesystem, this function fails with `error-code::not-permitted`. + /// + /// Note: This is similar to `readlinkat` in POSIX. + @since(version = 0.2.0) + readlink-at: func(path: string) -> result; + /// Remove a directory. + /// + /// Return `error-code::not-empty` if the directory is not empty. + /// + /// Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX. + @since(version = 0.2.0) + remove-directory-at: func(path: string) -> result<_, error-code>; + /// Rename a filesystem object. + /// + /// Note: This is similar to `renameat` in POSIX. + @since(version = 0.2.0) + rename-at: func(old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Create a symbolic link (also known as a "symlink"). + /// + /// If `old-path` starts with `/`, the function fails with + /// `error-code::not-permitted`. + /// + /// Note: This is similar to `symlinkat` in POSIX. + @since(version = 0.2.0) + symlink-at: func(old-path: string, new-path: string) -> result<_, error-code>; + /// Unlink a filesystem object that is not a directory. + /// + /// Return `error-code::is-directory` if the path refers to a directory. + /// Note: This is similar to `unlinkat(fd, path, 0)` in POSIX. + @since(version = 0.2.0) + unlink-file-at: func(path: string) -> result<_, error-code>; + /// Test whether two descriptors refer to the same filesystem object. + /// + /// In POSIX, this corresponds to testing whether the two descriptors have the + /// same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers. + /// wasi-filesystem does not expose device and inode numbers, so this function + /// may be used instead. + @since(version = 0.2.0) + is-same-object: func(other: borrow) -> bool; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a descriptor. + /// + /// This returns a hash of the last-modification timestamp and file size, and + /// may also include the inode number, device number, birth timestamp, and + /// other metadata fields that may change when the file is modified or + /// replaced. It may also include a secret value chosen by the + /// implementation and not otherwise exposed. + /// + /// Implementations are encouraged to provide the following properties: + /// + /// - If the file is not modified or replaced, the computed hash value should + /// usually not change. + /// - If the object is modified or replaced, the computed hash value should + /// usually change. + /// - The inputs to the hash should not be easily computable from the + /// computed hash. + /// + /// However, none of these is required. + @since(version = 0.2.0) + metadata-hash: func() -> result; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a directory descriptor and a relative path. + /// + /// This performs the same hash computation as `metadata-hash`. + @since(version = 0.2.0) + metadata-hash-at: func(path-flags: path-flags, path: string) -> result; + } + + /// A stream of directory entries. + @since(version = 0.2.0) + resource directory-entry-stream { + /// Read a single directory entry from a `directory-entry-stream`. + @since(version = 0.2.0) + read-directory-entry: func() -> result, error-code>; + } + + /// Attempts to extract a filesystem-related `error-code` from the stream + /// `error` provided. + /// + /// Stream operations which return `stream-error::last-operation-failed` + /// have a payload with more information about the operation that failed. + /// This payload can be passed through to this function to see if there's + /// filesystem-related information about the error to return. + /// + /// Note that this function is fallible because not all stream-related + /// errors are filesystem-related errors. + @since(version = 0.2.0) + filesystem-error-code: func(err: borrow) -> option; +} + +@since(version = 0.2.0) +interface preopens { + @since(version = 0.2.0) + use types.{descriptor}; + + /// Return the set of preopened directories, and their paths. + @since(version = 0.2.0) + get-directories: func() -> list>; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import preopens; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/http.wit b/crates/cpex-wasm-plugin/wit/deps/http.wit new file mode 100644 index 00000000..eb1b25f0 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/http.wit @@ -0,0 +1,733 @@ +package wasi:http@0.2.6; + +/// This interface defines all of the types and methods for implementing +/// HTTP Requests and Responses, both incoming and outgoing, as well as +/// their headers, trailers, and bodies. +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.6.{duration}; + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/error@0.2.6.{error as io-error}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + + /// This type corresponds to HTTP standard Methods. + @since(version = 0.2.0) + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string), + } + + /// This type corresponds to HTTP standard Related Schemes. + @since(version = 0.2.0) + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + @since(version = 0.2.0) + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.2.0) + record TLS-alert-received-payload { + alert-id: option, + alert-message: option, + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + @since(version = 0.2.0) + record field-size-payload { + field-name: option, + field-size: option, + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// + @since(version = 0.2.0) + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + configuration-error, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. It also includes an optional string for an + /// unstructured description of the error. Users should not depend on the + /// string for diagnosing errors, as it's not required to be consistent + /// between implementations. + internal-error(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + @since(version = 0.2.0) + variant header-error { + /// This error indicates that a `field-name` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + /// This error indicates that a forbidden `field-name` was used when trying + /// to set a header in a `fields`. + forbidden, + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + } + + /// Field keys are always strings. + /// + /// Field keys should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + /// + /// # Deprecation + /// + /// This type has been deprecated in favor of the `field-name` type. + @since(version = 0.2.0) + @deprecated(version = 0.2.2) + type field-key = string; + + /// Field names are always strings. + /// + /// Field names should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + @since(version = 0.2.1) + type field-name = field-key; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + @since(version = 0.2.0) + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `incoming-request.headers`, `outgoing-request.headers`) might be + /// immutable. In an immutable fields, the `set`, `append`, and `delete` + /// operations will fail with `header-error.immutable`. + @since(version = 0.2.0) + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + @since(version = 0.2.0) + constructor(); + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The tuple is a pair of the field name, represented as a string, and + /// Value, represented as a list of bytes. + /// + /// An error result will be returned if any `field-name` or `field-value` is + /// syntactically invalid, or if a field is forbidden. + @since(version = 0.2.0) + from-list: static func(entries: list>) -> result; + /// Get all of the values corresponding to a name. If the name is not present + /// in this `fields` or is syntactically invalid, an empty list is returned. + /// However, if the name is present but empty, this is represented by a list + /// with one or more empty field-values present. + @since(version = 0.2.0) + get: func(name: field-name) -> list; + /// Returns `true` when the name is present in this `fields`. If the name is + /// syntactically invalid, `false` is returned. + @since(version = 0.2.0) + has: func(name: field-name) -> bool; + /// Set all of the values for a name. Clears any existing values for that + /// name, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or any of + /// the `field-value`s are syntactically invalid. + @since(version = 0.2.0) + set: func(name: field-name, value: list) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` is + /// syntactically invalid. + @since(version = 0.2.0) + delete: func(name: field-name) -> result<_, header-error>; + /// Append a value for a name. Does not change or delete any existing + /// values for that name. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or + /// `field-value` are syntactically invalid. + @since(version = 0.2.0) + append: func(name: field-name, value: field-value) -> result<_, header-error>; + /// Retrieve the full set of names and values in the Fields. Like the + /// constructor, the list represents each name-value pair. + /// + /// The outer list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The names and values are always returned in the original casing and in + /// the order in which they will be serialized for transport. + @since(version = 0.2.0) + entries: func() -> list>; + /// Make a deep copy of the Fields. Equivalent in behavior to calling the + /// `fields` constructor on the return value of `entries`. The resulting + /// `fields` is mutable. + @since(version = 0.2.0) + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + @since(version = 0.2.0) + type headers = fields; + + /// Trailers is an alias for Fields. + @since(version = 0.2.0) + type trailers = fields; + + /// Represents an incoming HTTP Request. + @since(version = 0.2.0) + resource incoming-request { + /// Returns the method of the incoming request. + @since(version = 0.2.0) + method: func() -> method; + /// Returns the path with query parameters from the request, as a string. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Returns the protocol scheme from the request. + @since(version = 0.2.0) + scheme: func() -> option; + /// Returns the authority of the Request's target URI, if present. + @since(version = 0.2.0) + authority: func() -> option; + /// Get the `headers` associated with the request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// The `headers` returned are a child resource: it must be dropped before + /// the parent `incoming-request` is dropped. Dropping this + /// `incoming-request` before all children are dropped will trap. + @since(version = 0.2.0) + headers: func() -> headers; + /// Gives the `incoming-body` associated with this request. Will only + /// return success at most once, and subsequent calls will return error. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an outgoing HTTP Request. + @since(version = 0.2.0) + resource outgoing-request { + /// Construct a new `outgoing-request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// * `headers` is the HTTP Headers for the Request. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, an `outgoing-request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `outgoing-handler.handle` implementation + /// to reject invalid constructions of `outgoing-request`. + @since(version = 0.2.0) + constructor(headers: headers); + /// Returns the resource corresponding to the outgoing Body for this + /// Request. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-request` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + /// Get the Method for the Request. + @since(version = 0.2.0) + method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + @since(version = 0.2.0) + set-method: func(method: method) -> result; + /// Get the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + @since(version = 0.2.0) + set-path-with-query: func(path-with-query: option) -> result; + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + @since(version = 0.2.0) + scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + @since(version = 0.2.0) + set-scheme: func(scheme: option) -> result; + /// Get the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. + @since(version = 0.2.0) + authority: func() -> option; + /// Set the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid URI authority. + @since(version = 0.2.0) + set-authority: func(authority: option) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound a + /// blocking call to `wasi:io/poll.poll`. + @since(version = 0.2.0) + resource request-options { + /// Construct a default `request-options` value. + @since(version = 0.2.0) + constructor(); + /// The timeout for the initial connect to the HTTP Server. + @since(version = 0.2.0) + connect-timeout: func() -> option; + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-connect-timeout: func(duration: option) -> result; + /// The timeout for receiving the first byte of the Response body. + @since(version = 0.2.0) + first-byte-timeout: func() -> option; + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-first-byte-timeout: func(duration: option) -> result; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + @since(version = 0.2.0) + between-bytes-timeout: func() -> option; + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported. + @since(version = 0.2.0) + set-between-bytes-timeout: func(duration: option) -> result; + } + + /// Represents the ability to send an HTTP Response. + /// + /// This resource is used by the `wasi:http/incoming-handler` interface to + /// allow a Response to be sent corresponding to the Request provided as the + /// other argument to `incoming-handler.handle`. + @since(version = 0.2.0) + resource response-outparam { + /// Send an HTTP 1xx response. + /// + /// Unlike `response-outparam.set`, this does not consume the + /// `response-outparam`, allowing the guest to send an arbitrary number of + /// informational responses before sending the final response using + /// `response-outparam.set`. + /// + /// This will return an `HTTP-protocol-error` if `status` is not in the + /// range [100-199], or an `internal-error` if the implementation does not + /// support informational responses. + @unstable(feature = informational-outbound-responses) + send-informational: func(status: u16, headers: headers) -> result<_, error-code>; + /// Set the value of the `response-outparam` to either send a response, + /// or indicate an error. + /// + /// This method consumes the `response-outparam` to ensure that it is + /// called at most once. If it is never called, the implementation + /// will respond with an error. + /// + /// The user may provide an `error` to `response` to allow the + /// implementation determine how to respond with an HTTP error response. + @since(version = 0.2.0) + set: static func(param: response-outparam, response: result); + } + + /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.2.0) + type status-code = u16; + + /// Represents an incoming HTTP Response. + @since(version = 0.2.0) + resource incoming-response { + /// Returns the status code from the incoming response. + @since(version = 0.2.0) + status: func() -> status-code; + /// Returns the headers from the incoming response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `incoming-response` is dropped. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the incoming body. May be called at most once. Returns error + /// if called additional times. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an incoming HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, indicating that the full contents of the + /// body have been received. This resource represents the contents as + /// an `input-stream` and the delivery of trailers as a `future-trailers`, + /// and ensures that the user of this interface may only be consuming either + /// the body contents or waiting on trailers at any given time. + @since(version = 0.2.0) + resource incoming-body { + /// Returns the contents of the body, as a stream of bytes. + /// + /// Returns success on first call: the stream representing the contents + /// can be retrieved at most once. Subsequent calls will return error. + /// + /// The returned `input-stream` resource is a child: it must be dropped + /// before the parent `incoming-body` is dropped, or consumed by + /// `incoming-body.finish`. + /// + /// This invariant ensures that the implementation can determine whether + /// the user is consuming the contents of the body, waiting on the + /// `future-trailers` to be ready, or neither. This allows for network + /// backpressure is to be applied when the user is consuming the body, + /// and for that backpressure to not inhibit delivery of the trailers if + /// the user does not read the entire body. + @since(version = 0.2.0) + %stream: func() -> result; + /// Takes ownership of `incoming-body`, and returns a `future-trailers`. + /// This function will trap if the `input-stream` child is still alive. + @since(version = 0.2.0) + finish: static func(this: incoming-body) -> future-trailers; + } + + /// Represents a future which may eventually return trailers, or an error. + /// + /// In the case that the incoming HTTP Request or Response did not have any + /// trailers, this future will resolve to the empty set of trailers once the + /// complete Request or Response body has been received. + @since(version = 0.2.0) + resource future-trailers { + /// Returns a pollable which becomes ready when either the trailers have + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the contents of the trailers, or an error which occurred, + /// once the future is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the trailers or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the HTTP Request or Response + /// body, as well as any trailers, were received successfully, or that an + /// error occurred receiving them. The optional `trailers` indicates whether + /// or not trailers were present in the body. + /// + /// When some `trailers` are returned by this method, the `trailers` + /// resource is immutable, and a child. Use of the `set`, `append`, or + /// `delete` methods will return an error, and the resource must be + /// dropped before the parent `future-trailers` is dropped. + @since(version = 0.2.0) + get: func() -> option, error-code>>>; + } + + /// Represents an outgoing HTTP Response. + @since(version = 0.2.0) + resource outgoing-response { + /// Construct an `outgoing-response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// * `headers` is the HTTP Headers for the Response. + @since(version = 0.2.0) + constructor(headers: headers); + /// Get the HTTP Status Code for the Response. + @since(version = 0.2.0) + status-code: func() -> status-code; + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + @since(version = 0.2.0) + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the resource corresponding to the outgoing Body for this Response. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-response` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + } + + /// Represents an outgoing HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, inducating the full contents of the body + /// have been sent. This resource represents the contents as an + /// `output-stream` child resource, and the completion of the body (with + /// optional trailers) with a static function that consumes the + /// `outgoing-body` resource, and ensures that the user of this interface + /// may not write to the body contents after the body has been finished. + /// + /// If the user code drops this resource, as opposed to calling the static + /// method `finish`, the implementation should treat the body as incomplete, + /// and that an error has occurred. The implementation should propagate this + /// error to the HTTP protocol by whatever means it has available, + /// including: corrupting the body on the wire, aborting the associated + /// Request, or sending a late status code for the Response. + @since(version = 0.2.0) + resource outgoing-body { + /// Returns a stream for writing the body contents. + /// + /// The returned `output-stream` is a child resource: it must be dropped + /// before the parent `outgoing-body` resource is dropped (or finished), + /// otherwise the `outgoing-body` drop or `finish` will trap. + /// + /// Returns success on the first call: the `output-stream` resource for + /// this `outgoing-body` may be retrieved at most once. Subsequent calls + /// will return error. + @since(version = 0.2.0) + write: func() -> result; + /// Finalize an outgoing body, optionally providing trailers. This must be + /// called to signal that the response is complete. If the `outgoing-body` + /// is dropped without calling `outgoing-body.finalize`, the implementation + /// should treat the body as corrupted. + /// + /// Fails if the body's `outgoing-request` or `outgoing-response` was + /// constructed with a Content-Length header, and the contents written + /// to the body (via `write`) does not match the value given in the + /// Content-Length. + @since(version = 0.2.0) + finish: static func(this: outgoing-body, trailers: option) -> result<_, error-code>; + } + + /// Represents a future which may eventually return an incoming HTTP + /// Response, or an error. + /// + /// This resource is returned by the `wasi:http/outgoing-handler` interface to + /// provide the HTTP Response corresponding to the sent Request. + @since(version = 0.2.0) + resource future-incoming-response { + /// Returns a pollable which becomes ready when either the Response has + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the incoming HTTP Response, or an error, once one is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the response or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the incoming HTTP Response + /// status and headers have received successfully, or that an error + /// occurred. Errors may also occur while consuming the response body, + /// but those will be reported by the `incoming-body` and its + /// `output-stream` child. + @since(version = 0.2.0) + get: func() -> option>>; + } + + /// Attempts to extract a http-related `error` from the wasi:io `error` + /// provided. + /// + /// Stream operations which return + /// `wasi:io/stream/stream-error::last-operation-failed` have a payload of + /// type `wasi:io/error/error` with more information about the operation + /// that failed. This payload can be passed through to this function to see + /// if there's http-related information about the error to return. + /// + /// Note that this function is fallible because not all io-errors are + /// http-related errors. + @since(version = 0.2.0) + http-error-code: func(err: borrow) -> option; +} + +/// This interface defines a handler of incoming HTTP Requests. It should +/// be exported by components which can respond to HTTP Requests. +@since(version = 0.2.0) +interface incoming-handler { + @since(version = 0.2.0) + use types.{incoming-request, response-outparam}; + + /// This function is invoked with an incoming HTTP Request, and a resource + /// `response-outparam` which provides the capability to reply with an HTTP + /// Response. The response is sent by calling the `response-outparam.set` + /// method, which allows execution to continue after the response has been + /// sent. This enables both streaming to the response body, and performing other + /// work. + /// + /// The implementor of this function must write a response to the + /// `response-outparam` before returning, or else the caller will respond + /// with an error on its behalf. + @since(version = 0.2.0) + handle: func(request: incoming-request, response-out: response-outparam); +} + +/// This interface defines a handler of outgoing HTTP Requests. It should be +/// imported by components which wish to make HTTP Requests. +@since(version = 0.2.0) +interface outgoing-handler { + @since(version = 0.2.0) + use types.{outgoing-request, request-options, future-incoming-response, error-code}; + + /// This function is invoked with an outgoing HTTP Request, and it returns + /// a resource `future-incoming-response` which represents an HTTP Response + /// which may arrive in the future. + /// + /// The `options` argument accepts optional parameters for the HTTP + /// protocol's transport layer. + /// + /// This function may return an error if the `outgoing-request` is invalid + /// or not allowed to be made. Otherwise, protocol errors are reported + /// through the `future-incoming-response`. + @since(version = 0.2.0) + handle: func(request: outgoing-request, options: option) -> result; +} + +/// The `wasi:http/imports` world imports all the APIs for HTTP proxies. +/// It is intended to be `include`d in other worlds. +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; +} +/// The `wasi:http/proxy` world captures a widely-implementable intersection of +/// hosts that includes HTTP forward and reverse proxies. Components targeting +/// this world may concurrently stream in and out any number of incoming and +/// outgoing HTTP requests. +@since(version = 0.2.0) +world proxy { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; + + @since(version = 0.2.0) + export incoming-handler; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/io.wit b/crates/cpex-wasm-plugin/wit/deps/io.wit new file mode 100644 index 00000000..08ad78e6 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/io.wit @@ -0,0 +1,331 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) + to-debug-string: func() -> string; + } +} + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) + resource pollable { + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) + ready: func() -> bool; + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + @since(version = 0.2.0) + block: func(); + } + + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) + poll: func(in: list>) -> list; +} + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) +interface streams { + @since(version = 0.2.0) + use error.{error}; + @since(version = 0.2.0) + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed, + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) + read: func(len: u64) -> result, stream-error>; + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) + blocking-read: func(len: u64) -> result, stream-error>; + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) + skip: func(len: u64) -> result; + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) + blocking-skip: func(len: u64) -> result; + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) + check-write: func() -> result; + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) + write: func(contents: list) -> result<_, stream-error>; + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write`, and `flush`, and is implemented with the + /// following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while !contents.is_empty() { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, contents.len()); + /// let (chunk, rest) = contents.split_at(len); + /// this.write(chunk ); // eliding error handling + /// contents = rest; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) + flush: func() -> result<_, stream-error>; + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) + blocking-flush: func() -> result<_, stream-error>; + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) + write-zeroes: func(len: u64) -> result<_, stream-error>; + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with + /// the following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while num_zeroes != 0 { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, num_zeroes); + /// this.write-zeroes(len); // eliding error handling + /// num_zeroes -= len; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) + splice: func(src: borrow, len: u64) -> result; + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) + blocking-splice: func(src: borrow, len: u64) -> result; + } +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import error; + @since(version = 0.2.0) + import poll; + @since(version = 0.2.0) + import streams; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/random.wit b/crates/cpex-wasm-plugin/wit/deps/random.wit new file mode 100644 index 00000000..73edf5b6 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/random.wit @@ -0,0 +1,92 @@ +package wasi:random@0.2.6; + +/// The insecure-seed interface for seeding hash-map DoS resistance. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure-seed { + /// Return a 128-bit value that may contain a pseudo-random value. + /// + /// The returned value is not required to be computed from a CSPRNG, and may + /// even be entirely deterministic. Host implementations are encouraged to + /// provide pseudo-random values to any program exposed to + /// attacker-controlled content, to enable DoS protection built into many + /// languages' hash-map implementations. + /// + /// This function is intended to only be called once, by a source language + /// to initialize Denial Of Service (DoS) protection in its hash-map + /// implementation. + /// + /// # Expected future evolution + /// + /// This will likely be changed to a value import, to prevent it from being + /// called multiple times and potentially used for purposes other than DoS + /// protection. + @since(version = 0.2.0) + insecure-seed: func() -> tuple; +} + +/// The insecure interface for insecure pseudo-random numbers. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure { + /// Return `len` insecure pseudo-random bytes. + /// + /// This function is not cryptographically secure. Do not use it for + /// anything related to security. + /// + /// There are no requirements on the values of the returned bytes, however + /// implementations are encouraged to return evenly distributed values with + /// a long period. + @since(version = 0.2.0) + get-insecure-random-bytes: func(len: u64) -> list; + + /// Return an insecure pseudo-random `u64` value. + /// + /// This function returns the same type of pseudo-random data as + /// `get-insecure-random-bytes`, represented as a `u64`. + @since(version = 0.2.0) + get-insecure-random-u64: func() -> u64; +} + +/// WASI Random is a random data API. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface random { + /// Return `len` cryptographically-secure random or pseudo-random bytes. + /// + /// This function must produce data at least as cryptographically secure and + /// fast as an adequately seeded cryptographically-secure pseudo-random + /// number generator (CSPRNG). It must not block, from the perspective of + /// the calling program, under any circumstances, including on the first + /// request and on requests for numbers of bytes. The returned data must + /// always be unpredictable. + /// + /// This function must always return fresh data. Deterministic environments + /// must omit this function, rather than implementing it with deterministic + /// data. + @since(version = 0.2.0) + get-random-bytes: func(len: u64) -> list; + + /// Return a cryptographically-secure random or pseudo-random `u64` value. + /// + /// This function returns the same type of data as `get-random-bytes`, + /// represented as a `u64`. + @since(version = 0.2.0) + get-random-u64: func() -> u64; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import random; + @since(version = 0.2.0) + import insecure; + @since(version = 0.2.0) + import insecure-seed; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/sockets.wit b/crates/cpex-wasm-plugin/wit/deps/sockets.wit new file mode 100644 index 00000000..db6d1a23 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/sockets.wit @@ -0,0 +1,949 @@ +package wasi:sockets@0.2.6; + +@since(version = 0.2.0) +interface network { + @unstable(feature = network-error-code) + use wasi:io/error@0.2.6.{error}; + + /// An opaque resource that represents access to (a subset of) the network. + /// This enables context-based security for networking. + /// There is no need for this to map 1:1 to a physical network interface. + @since(version = 0.2.0) + resource network; + + /// Error codes. + /// + /// In theory, every API can return any error code. + /// In practice, API's typically only return the errors documented per API + /// combined with a couple of errors that are always possible: + /// - `unknown` + /// - `access-denied` + /// - `not-supported` + /// - `out-of-memory` + /// - `concurrency-conflict` + /// + /// See each individual API for what the POSIX equivalents are. They sometimes differ per API. + @since(version = 0.2.0) + enum error-code { + /// Unknown error + unknown, + /// Access denied. + /// + /// POSIX equivalent: EACCES, EPERM + access-denied, + /// The operation is not supported. + /// + /// POSIX equivalent: EOPNOTSUPP + not-supported, + /// One of the arguments is invalid. + /// + /// POSIX equivalent: EINVAL + invalid-argument, + /// Not enough memory to complete the operation. + /// + /// POSIX equivalent: ENOMEM, ENOBUFS, EAI_MEMORY + out-of-memory, + /// The operation timed out before it could finish completely. + timeout, + /// This operation is incompatible with another asynchronous operation that is already in progress. + /// + /// POSIX equivalent: EALREADY + concurrency-conflict, + /// Trying to finish an asynchronous operation that: + /// - has not been started yet, or: + /// - was already finished by a previous `finish-*` call. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + not-in-progress, + /// The operation has been aborted because it could not be completed immediately. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + would-block, + /// The operation is not valid in the socket's current state. + invalid-state, + /// A new socket resource could not be created because of a system limit. + new-socket-limit, + /// A bind operation failed because the provided address is not an address that the `network` can bind to. + address-not-bindable, + /// A bind operation failed because the provided address is already in use or because there are no ephemeral ports available. + address-in-use, + /// The remote address is not reachable + remote-unreachable, + /// The TCP connection was forcefully rejected + connection-refused, + /// The TCP connection was reset. + connection-reset, + /// A TCP connection was aborted. + connection-aborted, + /// The size of a datagram sent to a UDP socket exceeded the maximum + /// supported size. + datagram-too-large, + /// Name does not exist or has no suitable associated IP addresses. + name-unresolvable, + /// A temporary failure in name resolution occurred. + temporary-resolver-failure, + /// A permanent failure in name resolution occurred. + permanent-resolver-failure, + } + + @since(version = 0.2.0) + enum ip-address-family { + /// Similar to `AF_INET` in POSIX. + ipv4, + /// Similar to `AF_INET6` in POSIX. + ipv6, + } + + @since(version = 0.2.0) + type ipv4-address = tuple; + + @since(version = 0.2.0) + type ipv6-address = tuple; + + @since(version = 0.2.0) + variant ip-address { + ipv4(ipv4-address), + ipv6(ipv6-address), + } + + @since(version = 0.2.0) + record ipv4-socket-address { + /// sin_port + port: u16, + /// sin_addr + address: ipv4-address, + } + + @since(version = 0.2.0) + record ipv6-socket-address { + /// sin6_port + port: u16, + /// sin6_flowinfo + flow-info: u32, + /// sin6_addr + address: ipv6-address, + /// sin6_scope_id + scope-id: u32, + } + + @since(version = 0.2.0) + variant ip-socket-address { + ipv4(ipv4-socket-address), + ipv6(ipv6-socket-address), + } + + /// Attempts to extract a network-related `error-code` from the stream + /// `error` provided. + /// + /// Stream operations which return `stream-error::last-operation-failed` + /// have a payload with more information about the operation that failed. + /// This payload can be passed through to this function to see if there's + /// network-related information about the error to return. + /// + /// Note that this function is fallible because not all stream-related + /// errors are network-related errors. + @unstable(feature = network-error-code) + network-error-code: func(err: borrow) -> option; +} + +/// This interface provides a value-export of the default network handle.. +@since(version = 0.2.0) +interface instance-network { + @since(version = 0.2.0) + use network.{network}; + + /// Get a handle to the default network. + @since(version = 0.2.0) + instance-network: func() -> network; +} + +@since(version = 0.2.0) +interface ip-name-lookup { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-address}; + + @since(version = 0.2.0) + resource resolve-address-stream { + /// Returns the next address from the resolver. + /// + /// This function should be called multiple times. On each call, it will + /// return the next address in connection order preference. If all + /// addresses have been exhausted, this function returns `none`. + /// + /// This function never returns IPv4-mapped IPv6 addresses. + /// + /// # Typical errors + /// - `name-unresolvable`: Name does not exist or has no suitable associated IP addresses. (EAI_NONAME, EAI_NODATA, EAI_ADDRFAMILY) + /// - `temporary-resolver-failure`: A temporary failure in name resolution occurred. (EAI_AGAIN) + /// - `permanent-resolver-failure`: A permanent failure in name resolution occurred. (EAI_FAIL) + /// - `would-block`: A result is not available yet. (EWOULDBLOCK, EAGAIN) + @since(version = 0.2.0) + resolve-next-address: func() -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// Resolve an internet host name to a list of IP addresses. + /// + /// Unicode domain names are automatically converted to ASCII using IDNA encoding. + /// If the input is an IP address string, the address is parsed and returned + /// as-is without making any external requests. + /// + /// See the wasi-socket proposal README.md for a comparison with getaddrinfo. + /// + /// This function never blocks. It either immediately fails or immediately + /// returns successfully with a `resolve-address-stream` that can be used + /// to (asynchronously) fetch the results. + /// + /// # Typical errors + /// - `invalid-argument`: `name` is a syntactically invalid domain name or IP address. + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + resolve-addresses: func(network: borrow, name: string) -> result; +} + +@since(version = 0.2.0) +interface tcp { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.6.{duration}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + @since(version = 0.2.0) + enum shutdown-type { + /// Similar to `SHUT_RD` in POSIX. + receive, + /// Similar to `SHUT_WR` in POSIX. + send, + /// Similar to `SHUT_RDWR` in POSIX. + both, + } + + /// A TCP socket resource. + /// + /// The socket can be in one of the following states: + /// - `unbound` + /// - `bind-in-progress` + /// - `bound` (See note below) + /// - `listen-in-progress` + /// - `listening` + /// - `connect-in-progress` + /// - `connected` + /// - `closed` + /// See + /// for more information. + /// + /// Note: Except where explicitly mentioned, whenever this documentation uses + /// the term "bound" without backticks it actually means: in the `bound` state *or higher*. + /// (i.e. `bound`, `listen-in-progress`, `listening`, `connect-in-progress` or `connected`) + /// + /// In addition to the general error codes documented on the + /// `network::error-code` type, TCP socket methods may always return + /// `error(invalid-state)` when in the `closed` state. + @since(version = 0.2.0) + resource tcp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the TCP/UDP port is zero, the socket will be bound to a random free port. + /// + /// Bind can be attempted multiple times on the same socket, even with + /// different arguments on each iteration. But never concurrently and + /// only as long as the previous bind failed. Once a bind succeeds, the + /// binding can't be changed anymore. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-argument`: `local-address` is not a unicast address. (EINVAL) + /// - `invalid-argument`: `local-address` is an IPv4-mapped IPv6 address. (EINVAL) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// When binding to a non-zero port, this bind operation shouldn't be affected by the TIME_WAIT + /// state of a recently closed socket on the same local address. In practice this means that the SO_REUSEADDR + /// socket option should be set implicitly on all platforms, except on Windows where this is the default behavior + /// and SO_REUSEADDR performs something different entirely. + /// + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Connect to a remote endpoint. + /// + /// On success: + /// - the socket is transitioned into the `connected` state. + /// - a pair of streams is returned that can be used to read & write to the connection + /// + /// After a failed connection attempt, the socket will be in the `closed` + /// state and the only valid action left is to `drop` the socket. A single + /// socket can not be used to connect more than once. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: `remote-address` is not a unicast address. (EINVAL, ENETUNREACH on Linux, EAFNOSUPPORT on MacOS) + /// - `invalid-argument`: `remote-address` is an IPv4-mapped IPv6 address. (EINVAL, EADDRNOTAVAIL on Illumos) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The socket is already attached to a different network. The `network` passed to `connect` must be identical to the one passed to `bind`. + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN) + /// - `invalid-state`: The socket is already in the `listening` state. (EOPNOTSUPP, EINVAL on Windows) + /// - `timeout`: Connection timed out. (ETIMEDOUT) + /// - `connection-refused`: The connection was forcefully rejected. (ECONNREFUSED) + /// - `connection-reset`: The connection was reset. (ECONNRESET) + /// - `connection-aborted`: The connection was aborted. (ECONNABORTED) + /// - `remote-unreachable`: The remote address is not reachable. (EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `not-in-progress`: A connect operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// The POSIX equivalent of `start-connect` is the regular `connect` syscall. + /// Because all WASI sockets are non-blocking this is expected to return + /// EINPROGRESS, which should be translated to `ok()` in WASI. + /// + /// The POSIX equivalent of `finish-connect` is a `poll` for event `POLLOUT` + /// with a timeout of 0 on the socket descriptor. Followed by a check for + /// the `SO_ERROR` socket option, in case the poll signaled readiness. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-connect: func(network: borrow, remote-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-connect: func() -> result, error-code>; + /// Start listening for new connections. + /// + /// Transitions the socket into the `listening` state. + /// + /// Unlike POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. (EDESTADDRREQ) + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN, EINVAL on BSD) + /// - `invalid-state`: The socket is already in the `listening` state. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE) + /// - `not-in-progress`: A listen operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the listen operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `listen` as part of either `start-listen` or `finish-listen`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-listen: func() -> result<_, error-code>; + @since(version = 0.2.0) + finish-listen: func() -> result<_, error-code>; + /// Accept a new client socket. + /// + /// The returned socket is bound and in the `connected` state. The following properties are inherited from the listener socket: + /// - `address-family` + /// - `keep-alive-enabled` + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// - `hop-limit` + /// - `receive-buffer-size` + /// - `send-buffer-size` + /// + /// On success, this function returns the newly accepted client socket along with + /// a pair of streams that can be used to read & write to the connection. + /// + /// # Typical errors + /// - `invalid-state`: Socket is not in the `listening` state. (EINVAL) + /// - `would-block`: No pending connections at the moment. (EWOULDBLOCK, EAGAIN) + /// - `connection-aborted`: An incoming connection was pending, but was terminated by the client before this listener could accept it. (ECONNABORTED) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + accept: func() -> result, error-code>; + /// Get the bound local address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the remote address. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not connected to a remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether the socket is in the `listening` state. + /// + /// Equivalent to the SO_ACCEPTCONN socket option. + @since(version = 0.2.0) + is-listening: func() -> bool; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Hints the desired listen queue size. Implementations are free to ignore this. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// + /// # Typical errors + /// - `not-supported`: (set) The platform does not support changing the backlog size after the initial listen. + /// - `invalid-argument`: (set) The provided value was 0. + /// - `invalid-state`: (set) The socket is in the `connect-in-progress` or `connected` state. + @since(version = 0.2.0) + set-listen-backlog-size: func(value: u64) -> result<_, error-code>; + /// Enables or disables keepalive. + /// + /// The keepalive behavior can be adjusted using: + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// These properties can be configured while `keep-alive-enabled` is false, but only come into effect when `keep-alive-enabled` is true. + /// + /// Equivalent to the SO_KEEPALIVE socket option. + @since(version = 0.2.0) + keep-alive-enabled: func() -> result; + @since(version = 0.2.0) + set-keep-alive-enabled: func(value: bool) -> result<_, error-code>; + /// Amount of time the connection has to be idle before TCP starts sending keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPIDLE socket option. (TCP_KEEPALIVE on MacOS) + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-idle-time: func() -> result; + @since(version = 0.2.0) + set-keep-alive-idle-time: func(value: duration) -> result<_, error-code>; + /// The time between keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPINTVL socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-interval: func() -> result; + @since(version = 0.2.0) + set-keep-alive-interval: func(value: duration) -> result<_, error-code>; + /// The maximum amount of keepalive packets TCP should send before aborting the connection. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPCNT socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-count: func() -> result; + @since(version = 0.2.0) + set-keep-alive-count: func(value: u32) -> result<_, error-code>; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + hop-limit: func() -> result; + @since(version = 0.2.0) + set-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which can be used to poll for, or block on, + /// completion of any of the asynchronous operations of this socket. + /// + /// When `finish-bind`, `finish-listen`, `finish-connect` or `accept` + /// return `error(would-block)`, this pollable can be used to wait for + /// their success or failure, after which the method can be retried. + /// + /// The pollable is not limited to the async operation that happens to be + /// in progress at the time of calling `subscribe` (if any). Theoretically, + /// `subscribe` only has to be called once per socket and can then be + /// (re)used for the remainder of the socket's lifetime. + /// + /// See + /// for more information. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Initiate a graceful shutdown. + /// + /// - `receive`: The socket is not expecting to receive any data from + /// the peer. The `input-stream` associated with this socket will be + /// closed. Any data still in the receive queue at time of calling + /// this method will be discarded. + /// - `send`: The socket has no more data to send to the peer. The `output-stream` + /// associated with this socket will be closed and a FIN packet will be sent. + /// - `both`: Same effect as `receive` & `send` combined. + /// + /// This function is idempotent; shutting down a direction more than once + /// has no effect and returns `ok`. + /// + /// The shutdown function does not close (drop) the socket. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not in the `connected` state. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + shutdown: func(shutdown-type: shutdown-type) -> result<_, error-code>; + } +} + +@since(version = 0.2.0) +interface tcp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use tcp.{tcp-socket}; + + /// Create a new TCP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_STREAM, IPPROTO_TCP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind`/`connect` + /// is called, the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-tcp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +interface udp { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + /// A received datagram. + @since(version = 0.2.0) + record incoming-datagram { + /// The payload. + /// + /// Theoretical max size: ~64 KiB. In practice, typically less than 1500 bytes. + data: list, + /// The source address. + /// + /// This field is guaranteed to match the remote address the stream was initialized with, if any. + /// + /// Equivalent to the `src_addr` out parameter of `recvfrom`. + remote-address: ip-socket-address, + } + + /// A datagram to be sent out. + @since(version = 0.2.0) + record outgoing-datagram { + /// The payload. + data: list, + /// The destination address. + /// + /// The requirements on this field depend on how the stream was initialized: + /// - with a remote address: this field must be None or match the stream's remote address exactly. + /// - without a remote address: this field is required. + /// + /// If this value is None, the send operation is equivalent to `send` in POSIX. Otherwise it is equivalent to `sendto`. + remote-address: option, + } + + /// A UDP socket handle. + @since(version = 0.2.0) + resource udp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the port is zero, the socket will be bound to a random free port. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Set up inbound & outbound communication channels, optionally to a specific peer. + /// + /// This function only changes the local socket configuration and does not generate any network traffic. + /// On success, the `remote-address` of the socket is updated. The `local-address` may be updated as well, + /// based on the best network path to `remote-address`. + /// + /// When a `remote-address` is provided, the returned streams are limited to communicating with that specific peer: + /// - `send` can only be used to send to this destination. + /// - `receive` will only return datagrams sent from the provided `remote-address`. + /// + /// This method may be called multiple times on the same socket to change its association, but + /// only the most recently returned pair of streams will be operational. Implementations may trap if + /// the streams returned by a previous invocation haven't been dropped yet before calling `stream` again. + /// + /// The POSIX equivalent in pseudo-code is: + /// ```text + /// if (was previously connected) { + /// connect(s, AF_UNSPEC) + /// } + /// if (remote_address is Some) { + /// connect(s, remote_address) + /// } + /// ``` + /// + /// Unlike in POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-state`: The socket is not bound. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + %stream: func(remote-address: option) -> result, error-code>; + /// Get the current bound address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the address the socket is currently streaming to. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not streaming to a specific remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + unicast-hop-limit: func() -> result; + @since(version = 0.2.0) + set-unicast-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which will resolve once the socket is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource incoming-datagram-stream { + /// Receive messages on the socket. + /// + /// This function attempts to receive up to `max-results` datagrams on the socket without blocking. + /// The returned list may contain fewer elements than requested, but never more. + /// + /// This function returns successfully with an empty list when either: + /// - `max-results` is 0, or: + /// - `max-results` is greater than 0, but no results are immediately available. + /// This function never returns `error(would-block)`. + /// + /// # Typical errors + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + receive: func(max-results: u64) -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready to receive again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource outgoing-datagram-stream { + /// Check readiness for sending. This function never blocks. + /// + /// Returns the number of datagrams permitted for the next call to `send`, + /// or an error. Calling `send` with more datagrams than this function has + /// permitted will trap. + /// + /// When this function returns ok(0), the `subscribe` pollable will + /// become ready when this function will report at least ok(1), or an + /// error. + /// + /// Never returns `would-block`. + check-send: func() -> result; + /// Send messages on the socket. + /// + /// This function attempts to send all provided `datagrams` on the socket without blocking and + /// returns how many messages were actually sent (or queued for sending). This function never + /// returns `error(would-block)`. If none of the datagrams were able to be sent, `ok(0)` is returned. + /// + /// This function semantically behaves the same as iterating the `datagrams` list and sequentially + /// sending each individual datagram until either the end of the list has been reached or the first error occurred. + /// If at least one datagram has been sent successfully, this function never returns an error. + /// + /// If the input list is empty, the function returns `ok(0)`. + /// + /// Each call to `send` must be permitted by a preceding `check-send`. Implementations must trap if + /// either `check-send` was not called or `datagrams` contains more items than `check-send` permitted. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The socket is in "connected" mode and `remote-address` is `some` value that does not match the address passed to `stream`. (EISCONN) + /// - `invalid-argument`: The socket is not "connected" and no value for `remote-address` was provided. (EDESTADDRREQ) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// - `datagram-too-large`: The datagram is too large. (EMSGSIZE) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + send: func(datagrams: list) -> result; + /// Create a `pollable` which will resolve once the stream is ready to send again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } +} + +@since(version = 0.2.0) +interface udp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use udp.{udp-socket}; + + /// Create a new UDP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_DGRAM, IPPROTO_UDP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind` is called, + /// the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-udp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import network; + @since(version = 0.2.0) + import instance-network; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import udp; + @since(version = 0.2.0) + import udp-create-socket; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import tcp; + @since(version = 0.2.0) + import tcp-create-socket; + @since(version = 0.2.0) + import ip-name-lookup; +} diff --git a/crates/cpex-wasm-plugin/wit/world.wit b/crates/cpex-wasm-plugin/wit/world.wit new file mode 100644 index 00000000..19f6b3d8 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/world.wit @@ -0,0 +1,231 @@ +// Location: ./crates/cpex-wasm-plugin/wit/world.wit +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WIT world definition for the CPEX plugin component. +// Defines the types and exported function that the WASM host uses to invoke plugins. + +package cpex:plugin; + +interface types { + enum role { + system, + developer, + user, + assistant, + tool, + } + + enum channel { + analysis, + commentary, + final, + } + + enum resource-type { + file, + blob, + uri, + database, + api, + memory, + artifact, + } + + enum subject-type { + user, + agent, + service, + system, + } + + record image-source { + source-type: string, + data: string, + media-type: option, + } + + record video-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + record audio-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + record document-source { + source-type: string, + data: string, + media-type: option, + title: option, + } + + record tool-call { + tool-call-id: string, + name: string, + arguments: string, + namespace: option, + } + + record tool-result { + tool-call-id: string, + tool-name: string, + content: string, + is-error: bool, + } + + record cmf-resource { + resource-request-id: string, + uri: string, + name: option, + description: option, + resource-type: resource-type, + content: option, + blob: option>, + mime-type: option, + size-bytes: option, + annotations: string, + version: option, + } + + record resource-reference { + resource-request-id: string, + uri: string, + name: option, + resource-type: resource-type, + range-start: option, + range-end: option, + selector: option, + } + + record prompt-request { + prompt-request-id: string, + name: string, + arguments: string, + server-id: option, + } + + record prompt-result { + prompt-request-id: string, + prompt-name: string, + messages: string, + content: option, + is-error: bool, + error-message: option, + } + + variant content-part { + text(string), + thinking(string), + tool-call(tool-call), + tool-result(tool-result), + cmf-resource(cmf-resource), + resource-ref(resource-reference), + prompt-request(prompt-request), + prompt-result(prompt-result), + image(image-source), + video(video-source), + audio(audio-source), + document(document-source), + } + + record message { + schema-version: string, + role: role, + content: list, + channel: option, + } + + record message-payload { + message: message, + } + + record request-extension { + environment: option, + request-id: option, + timestamp: option, + trace-id: option, + span-id: option, + } + + record subject-extension { + id: option, + subject-type: option, + roles: list, + permissions: list, + teams: list, + claims: list>, + } + + record security-extension { + labels: list, + classification: option, + subject: option, + auth-method: option, + } + + record http-extension { + request-headers: list>, + response-headers: list>, + } + + record meta-extension { + entity-type: option, + entity-name: option, + tags: list, + scope: option, + properties: list>, + } + + record extensions { + request: option, + security: option, + http: option, + meta: option, + } + + record plugin-context { + local-state: string, + global-state: string, + } + + record plugin-violation { + code: string, + reason: string, + description: option, + details: string, + proto-error-code: option, + } + + record plugin-result { + continue-processing: bool, + modified-payload: option, + modified-extensions: option, + violation: option, + metadata: option, + } +} + +world plugin { + import wasi:io/poll@0.2.6; + import wasi:io/error@0.2.6; + import wasi:io/streams@0.2.6; + import wasi:clocks/monotonic-clock@0.2.6; + import wasi:http/types@0.2.6; + import wasi:http/outgoing-handler@0.2.6; + + use types.{message-payload, extensions, plugin-context, plugin-result}; + + export handle-hook: func( + payload: message-payload, + extensions: extensions, + ctx: plugin-context + ) -> plugin-result; +} From 987f79be24ed0f0089edacabf3f2805d6833b0d4 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Tue, 9 Jun 2026 14:24:45 -0400 Subject: [PATCH 10/11] Adding lightweight cpex-payload crate for adding plugin related functionality in wasm compilation Signed-off-by: Shriti Priya --- Cargo.lock | 1290 +++++++++++++++- Cargo.toml | 5 + crates/cpex-payload/Cargo.toml | 26 + crates/cpex-payload/src/cmf/constants.rs | 65 + crates/cpex-payload/src/cmf/content.rs | 486 ++++++ crates/cpex-payload/src/cmf/enums.rs | 176 +++ crates/cpex-payload/src/cmf/message.rs | 460 ++++++ crates/cpex-payload/src/cmf/mod.rs | 100 ++ crates/cpex-payload/src/cmf/view.rs | 864 +++++++++++ crates/cpex-payload/src/config.rs | 1297 +++++++++++++++++ crates/cpex-payload/src/context.rs | 195 +++ crates/cpex-payload/src/error.rs | 275 ++++ crates/cpex-payload/src/extensions/agent.rs | 60 + .../cpex-payload/src/extensions/completion.rs | 71 + .../cpex-payload/src/extensions/container.rs | 711 +++++++++ .../cpex-payload/src/extensions/delegation.rs | 165 +++ crates/cpex-payload/src/extensions/filter.rs | 547 +++++++ .../cpex-payload/src/extensions/framework.rs | 38 + crates/cpex-payload/src/extensions/guarded.rs | 144 ++ crates/cpex-payload/src/extensions/http.rs | 210 +++ crates/cpex-payload/src/extensions/llm.rs | 27 + crates/cpex-payload/src/extensions/mcp.rs | 115 ++ crates/cpex-payload/src/extensions/meta.rs | 45 + crates/cpex-payload/src/extensions/mod.rs | 52 + .../cpex-payload/src/extensions/monotonic.rs | 179 +++ .../cpex-payload/src/extensions/provenance.rs | 27 + crates/cpex-payload/src/extensions/request.rs | 35 + .../cpex-payload/src/extensions/security.rs | 348 +++++ crates/cpex-payload/src/extensions/tiers.rs | 100 ++ crates/cpex-payload/src/hooks/macros.rs | 70 + crates/cpex-payload/src/hooks/mod.rs | 27 + crates/cpex-payload/src/hooks/payload.rs | 133 ++ crates/cpex-payload/src/hooks/trait_def.rs | 322 ++++ crates/cpex-payload/src/hooks/types.rs | 190 +++ crates/cpex-payload/src/lib.rs | 32 + crates/cpex-payload/src/plugin.rs | 553 +++++++ .../src/plugins/identity_checker.rs | 75 + crates/cpex-payload/src/plugins/mod.rs | 1 + 38 files changed, 9489 insertions(+), 27 deletions(-) create mode 100644 crates/cpex-payload/Cargo.toml create mode 100644 crates/cpex-payload/src/cmf/constants.rs create mode 100644 crates/cpex-payload/src/cmf/content.rs create mode 100644 crates/cpex-payload/src/cmf/enums.rs create mode 100644 crates/cpex-payload/src/cmf/message.rs create mode 100644 crates/cpex-payload/src/cmf/mod.rs create mode 100644 crates/cpex-payload/src/cmf/view.rs create mode 100644 crates/cpex-payload/src/config.rs create mode 100644 crates/cpex-payload/src/context.rs create mode 100644 crates/cpex-payload/src/error.rs create mode 100644 crates/cpex-payload/src/extensions/agent.rs create mode 100644 crates/cpex-payload/src/extensions/completion.rs create mode 100644 crates/cpex-payload/src/extensions/container.rs create mode 100644 crates/cpex-payload/src/extensions/delegation.rs create mode 100644 crates/cpex-payload/src/extensions/filter.rs create mode 100644 crates/cpex-payload/src/extensions/framework.rs create mode 100644 crates/cpex-payload/src/extensions/guarded.rs create mode 100644 crates/cpex-payload/src/extensions/http.rs create mode 100644 crates/cpex-payload/src/extensions/llm.rs create mode 100644 crates/cpex-payload/src/extensions/mcp.rs create mode 100644 crates/cpex-payload/src/extensions/meta.rs create mode 100644 crates/cpex-payload/src/extensions/mod.rs create mode 100644 crates/cpex-payload/src/extensions/monotonic.rs create mode 100644 crates/cpex-payload/src/extensions/provenance.rs create mode 100644 crates/cpex-payload/src/extensions/request.rs create mode 100644 crates/cpex-payload/src/extensions/security.rs create mode 100644 crates/cpex-payload/src/extensions/tiers.rs create mode 100644 crates/cpex-payload/src/hooks/macros.rs create mode 100644 crates/cpex-payload/src/hooks/mod.rs create mode 100644 crates/cpex-payload/src/hooks/payload.rs create mode 100644 crates/cpex-payload/src/hooks/trait_def.rs create mode 100644 crates/cpex-payload/src/hooks/types.rs create mode 100644 crates/cpex-payload/src/lib.rs create mode 100644 crates/cpex-payload/src/plugin.rs create mode 100644 crates/cpex-payload/src/plugins/identity_checker.rs create mode 100644 crates/cpex-payload/src/plugins/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 36aebe0d..40036aa6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -35,6 +44,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -223,9 +238,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object", + "object 0.37.3", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arc-swap" version = "1.9.1" @@ -472,6 +493,9 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "byteorder" @@ -494,6 +518,74 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.52.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.52.0", + "winx", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix", + "winx", +] + [[package]] name = "cc" version = "1.2.62" @@ -659,6 +751,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colored" version = "3.1.1" @@ -693,8 +794,8 @@ dependencies = [ "serde-untagged", "serde_core", "serde_json", - "toml", - "winnow", + "toml 1.1.2+spec-1.1.0", + "winnow 1.0.3", "yaml-rust2", ] @@ -836,6 +937,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "cpex-payload" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "wildmatch", +] + [[package]] name = "cpex-sdk" version = "0.1.0" @@ -846,6 +962,32 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cpex-wasm-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "cpex-core", + "hyper", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-http", +] + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -864,6 +1006,148 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c4ebb31662e2051dcc49b7342d222405a99e951720756cc4b93315972abd67" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dfc2ec96ec1c3a8a250602e936712e00a381df032f7a8ad175c8f768c03bb" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5694aa8a2eb2571a15b3feee38d16ccaf2712200e7b5c9ae0479069bdfb46949" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ab349d30a5fad9699440ee7ccb435374e8a8735dcca26696a4245bcefcc47e" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e95970bdb51d145c828a114a1084cb8b63e65569a51600ad398cb49fa78b062" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.0", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8414b8ecc81f89f8a3f2c5cbc785b9ed690200cd6f9d780e96a92f88879704" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631c4e5db42e6a0f9e7a68f18f7faba3692862114870c7c598aee7e0e5677e59" + +[[package]] +name = "cranelift-control" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c6e92c825abfbb739a4beaa5db3988f98a96a68d6ea656f562098efc142976" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55e57cd185782abada9ab2606bfe88d0abc0d42d83a7d432dcf69991e17fe76e" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0f17e48d15e29552e2f264d302c31a661a830196d879bbdaf4d7b2bf2f7011" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "407b80b46934c9dce9a6581f0e80d079b7a69e11372fb03d7dce4c7ba3fee4e3" + +[[package]] +name = "cranelift-native" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a40e056e421d9a6c757983f2184f765ae1c28a51555d3877e98afc97ce5705b" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdbadda21e49798825a1ec795dab30bcb03235891f662b5ccf23fa45b39682f" + [[package]] name = "crc32fast" version = "1.5.0" @@ -882,6 +1166,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -986,6 +1289,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1077,6 +1389,27 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1189,6 +1522,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "ena" version = "0.14.4" @@ -1254,6 +1599,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "ff" version = "0.13.1" @@ -1286,6 +1637,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1330,6 +1687,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1430,6 +1798,20 @@ dependencies = [ "slab", ] +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1484,6 +1866,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "stable_deref_trait", +] + [[package]] name = "gloo-timers" version = "0.4.0" @@ -1563,6 +1957,11 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "hashlink" @@ -1717,7 +2116,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -1907,6 +2306,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.52.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "ipnet" version = "2.12.0" @@ -1938,15 +2353,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "jni" -version = "0.22.4" +name = "ittapi" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", "log", "simd_cesu8", "thiserror 2.0.18", @@ -2078,7 +2513,7 @@ dependencies = [ "ena", "itertools 0.14.0", "lalrpop-util", - "petgraph", + "petgraph 0.7.1", "pico-args", "regex", "regex-syntax", @@ -2108,6 +2543,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2122,9 +2563,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -2132,6 +2573,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -2150,6 +2600,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2218,12 +2674,36 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + [[package]] name = "miette" version = "7.6.0" @@ -2401,6 +2881,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "crc32fast", + "hashbrown 0.17.0", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2550,13 +3042,23 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "indexmap 2.14.0", ] @@ -2618,6 +3120,18 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2758,6 +3272,29 @@ dependencies = [ "cc", ] +[[package]] +name = "pulley-interpreter" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6ccdd6fc70f6c33eb21cb8fe05a71a5f7ee4cbef7a093a8a87dd8a908697cd" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e00101fdb6fcaf9ea98a1994be11861d7e0c910c718c41bae373c44179e3160c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2911,6 +3448,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2920,6 +3477,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -2940,6 +3508,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "regalloc2" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.0", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -3004,7 +3586,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -3135,6 +3717,12 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3156,6 +3744,29 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + [[package]] name = "rustls" version = "0.23.40" @@ -3163,6 +3774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -3623,6 +4235,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smol_str" @@ -3805,6 +4420,25 @@ dependencies = [ "libc", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "term" version = "1.2.1" @@ -3814,6 +4448,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3972,6 +4615,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -3980,9 +4638,18 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -4000,9 +4667,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -4358,6 +5031,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-compose" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ba953e2b9b4b4b52a31cf4e3ee1c1374c872b6e012cf2138d1c37cba00bfd6" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "log", + "petgraph 0.6.5", + "smallvec", + "wasm-encoder 0.248.0", + "wasmparser 0.248.0", + "wat", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -4365,7 +5055,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" +dependencies = [ + "leb128fmt", + "wasmparser 0.248.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +dependencies = [ + "leb128fmt", + "wasmparser 0.251.0", ] [[package]] @@ -4376,8 +5086,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -4392,6 +5102,395 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags", + "hashbrown 0.17.0", + "indexmap 2.14.0", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +dependencies = [ + "bitflags", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.248.0", +] + +[[package]] +name = "wasmtime" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c191891081c3bf9f10cb579819b32b0e81695641dc639eef34b57cf10c59ad81" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object 0.39.1", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.248.0", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "wat", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f337d68a62d868f3c297517b46d20dc7e293f0da36bbee2f6ec3c30eab938bd" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.0", + "indexmap 2.14.0", + "log", + "object 0.39.1", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder 0.248.0", + "wasmparser 0.248.0", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f45a4fb2a6a269100b845404f2210d874eaee9ecd9efb6fc6c1e80896fab40f" +dependencies = [ + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml 0.9.12+spec-1.1.0", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1cc8df1940657960571d7bc9bc0eadf869c0eb95cff831a6554409f89b25b0" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.248.0", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73ccd2e0e6bd91fb0161a17ee5e2d7badbeba6eb7461d0e0e3991492bd3835d" + +[[package]] +name = "wasmtime-internal-core" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110bf85122cd451d3b9ff67f8911d428ec9b729208abe950a0333c3244660e88" +dependencies = [ + "anyhow", + "hashbrown 0.17.0", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0236a21553c5b30ee953f413a8dc99729548b747219eef7e70f8288ed313ebe" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object 0.39.1", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ccd50931b61ad593ae91e62d41a96b997b26c9308305cae4523cfef9301d9e8" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0a52ca7429272234b44f81fdf80d4793593e5c368b0f960d41fd401b1117bec" +dependencies = [ + "cc", + "object 0.39.1", + "rustix", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa6818a4864719772680694f4e4649a8600bb5efcf71111ebaf7419b266463e8" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fdc6a69c42fbbf13104ccbf0d3c096d0392f00102e2c70b542d9fca402eb162" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object 0.39.1", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0bd1cd696ec4b7e6f46357c0479e7739c3858e54afb69a15765402bbc1f9dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2ca01234ef55acd10c0fa5a2f96c3fd422eba03b221e2e9572944d19a476a7" +dependencies = [ + "cranelift-codegen", + "gimli", + "log", + "object 0.39.1", + "target-lexicon", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b3d8cfaad2d33a92ec4ed93d69e9836c04f7eb5b8dfe1dde9df47c41f20b7" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap 2.14.0", + "wit-parser 0.248.0", +] + +[[package]] +name = "wasmtime-wasi" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d0c0121dcc5152390d099fb853b9a0d79e7e0a172c12cc668ea8c5d8f883c2" +dependencies = [ + "async-trait", + "bitflags", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-std", + "cap-time-ext", + "cfg-if", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", + "rand 0.10.1", + "rustix", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-wasi-http" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10e03132bf0eb02270f1a1efb8ad0144c3924e0eb8258543803866e47ab808ce" +dependencies = [ + "async-trait", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "rustls", + "tokio", + "tokio-rustls", + "tracing", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-io", + "webpki-roots 0.26.11", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02ca2fd53dbd62f5f95b470b43ff1e711933e8f30ebc89d9050351a72f6e5c28" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "251.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc7467dda0a96142eb2c980329dfb62480b1e1d3622fdeb1a44e2bca6ceed74" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width 0.2.2", + "wasm-encoder 0.251.0", +] + +[[package]] +name = "wat" +version = "1.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b1086c9e85b95bd6a229a928bc6c6d0662e42af0250c88d067b418831ea4d4" +dependencies = [ + "wast 251.0.0", +] + [[package]] name = "web-sys" version = "0.3.95" @@ -4421,6 +5520,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -4430,12 +5538,68 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wiggle" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4cd8015611a3bc95e449ad198dfd133b7488759b0dc49b515052101eb954587" +dependencies = [ + "bitflags", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0f655ec3eba7df68408018796911a6acdda3a41e67d6551a3766563495e333" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a22f38e7b0f2a3b58bae4dd655dcedd65d56f257916a5eda5f08c40910ee9d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wiggle-generate", +] + [[package]] name = "wildmatch" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4445,6 +5609,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "winch-codegen" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fdfab04a4d446c558a9ea994ded662d35580a17b15385b862002703aa43245" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4671,6 +5860,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.3" @@ -4680,6 +5875,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.52.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4697,7 +5902,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -4744,10 +5949,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", - "wit-parser", + "wasmparser 0.244.0", + "wit-parser 0.244.0", ] [[package]] @@ -4765,7 +5970,38 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856" +dependencies = [ + "anyhow", + "hashbrown 0.17.0", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.248.0", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index da736d6c..e7bc7d7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ members = [ "crates/cpex-orchestration", "crates/cpex-sdk", "crates/cpex-ffi", + "crates/cpex-payload", + "crates/cpex-wasm-host", "crates/apl-core", "crates/apl-cmf", "crates/apl-cpex", @@ -24,6 +26,9 @@ members = [ "crates/apl-audit-logger", "examples/go-demo/ffi", ] +exclude = [ + "crates/cpex-wasm-plugin", +] # `default-members` controls what `cargo build` / `cargo test` (with no # `-p` or `--workspace` flag) picks up. Cedarling integration crates diff --git a/crates/cpex-payload/Cargo.toml b/crates/cpex-payload/Cargo.toml new file mode 100644 index 00000000..6f11b5e3 --- /dev/null +++ b/crates/cpex-payload/Cargo.toml @@ -0,0 +1,26 @@ +# Location: ./crates/cpex-payload/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX Payload — minimal types for WASM plugins. + +[package] +name = "cpex-payload" +description = "CPEX payload types — minimal types for WASM plugins." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +# WASM-compatible tokio features only +tokio = { version = "1", features = ["sync", "macros", "io-util", "rt", "time"] } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +wildmatch = { workspace = true } diff --git a/crates/cpex-payload/src/cmf/constants.rs b/crates/cpex-payload/src/cmf/constants.rs new file mode 100644 index 00000000..12a8ac5e --- /dev/null +++ b/crates/cpex-payload/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-payload/src/cmf/content.rs b/crates/cpex-payload/src/cmf/content.rs new file mode 100644 index 00000000..3cde9b65 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/cmf/enums.rs b/crates/cpex-payload/src/cmf/enums.rs new file mode 100644 index 00000000..97f1dcc2 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/cmf/message.rs b/crates/cpex-payload/src/cmf/message.rs new file mode 100644 index 00000000..b2bad350 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/cmf/mod.rs b/crates/cpex-payload/src/cmf/mod.rs new file mode 100644 index 00000000..11ae76a4 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/cmf/view.rs b/crates/cpex-payload/src/cmf/view.rs new file mode 100644 index 00000000..2c92e76d --- /dev/null +++ b/crates/cpex-payload/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-payload/src/config.rs b/crates/cpex-payload/src/config.rs new file mode 100644 index 00000000..89d962a2 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/context.rs b/crates/cpex-payload/src/context.rs new file mode 100644 index 00000000..2176c7bc --- /dev/null +++ b/crates/cpex-payload/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-payload/src/error.rs b/crates/cpex-payload/src/error.rs new file mode 100644 index 00000000..576dc5bc --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/agent.rs b/crates/cpex-payload/src/extensions/agent.rs new file mode 100644 index 00000000..f6eb9c3b --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/completion.rs b/crates/cpex-payload/src/extensions/completion.rs new file mode 100644 index 00000000..2c3ad14d --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/container.rs b/crates/cpex-payload/src/extensions/container.rs new file mode 100644 index 00000000..6409bf43 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/delegation.rs b/crates/cpex-payload/src/extensions/delegation.rs new file mode 100644 index 00000000..e5f5ef50 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/filter.rs b/crates/cpex-payload/src/extensions/filter.rs new file mode 100644 index 00000000..1841164a --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/framework.rs b/crates/cpex-payload/src/extensions/framework.rs new file mode 100644 index 00000000..b4654055 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/guarded.rs b/crates/cpex-payload/src/extensions/guarded.rs new file mode 100644 index 00000000..fb369a16 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/http.rs b/crates/cpex-payload/src/extensions/http.rs new file mode 100644 index 00000000..3fa1157a --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/llm.rs b/crates/cpex-payload/src/extensions/llm.rs new file mode 100644 index 00000000..adc2225c --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/mcp.rs b/crates/cpex-payload/src/extensions/mcp.rs new file mode 100644 index 00000000..c5eed384 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/meta.rs b/crates/cpex-payload/src/extensions/meta.rs new file mode 100644 index 00000000..4ba55516 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/mod.rs b/crates/cpex-payload/src/extensions/mod.rs new file mode 100644 index 00000000..d51aec62 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/monotonic.rs b/crates/cpex-payload/src/extensions/monotonic.rs new file mode 100644 index 00000000..82530199 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/provenance.rs b/crates/cpex-payload/src/extensions/provenance.rs new file mode 100644 index 00000000..1873f521 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/request.rs b/crates/cpex-payload/src/extensions/request.rs new file mode 100644 index 00000000..435ab1fa --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/security.rs b/crates/cpex-payload/src/extensions/security.rs new file mode 100644 index 00000000..91d54c18 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/extensions/tiers.rs b/crates/cpex-payload/src/extensions/tiers.rs new file mode 100644 index 00000000..a22406f9 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/hooks/macros.rs b/crates/cpex-payload/src/hooks/macros.rs new file mode 100644 index 00000000..80012acf --- /dev/null +++ b/crates/cpex-payload/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-payload/src/hooks/mod.rs b/crates/cpex-payload/src/hooks/mod.rs new file mode 100644 index 00000000..1982fad1 --- /dev/null +++ b/crates/cpex-payload/src/hooks/mod.rs @@ -0,0 +1,27 @@ +// 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 macros; +pub mod payload; +pub mod trait_def; +pub mod types; + +// Re-export core types at the hooks level +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-payload/src/hooks/payload.rs b/crates/cpex-payload/src/hooks/payload.rs new file mode 100644 index 00000000..d284bf4c --- /dev/null +++ b/crates/cpex-payload/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-payload/src/hooks/trait_def.rs b/crates/cpex-payload/src/hooks/trait_def.rs new file mode 100644 index 00000000..b75d2b8a --- /dev/null +++ b/crates/cpex-payload/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-payload/src/hooks/types.rs b/crates/cpex-payload/src/hooks/types.rs new file mode 100644 index 00000000..3295d1a6 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/lib.rs b/crates/cpex-payload/src/lib.rs new file mode 100644 index 00000000..eab7fc78 --- /dev/null +++ b/crates/cpex-payload/src/lib.rs @@ -0,0 +1,32 @@ +// 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 extensions; +pub mod hooks; +pub mod plugin; +pub mod plugins; diff --git a/crates/cpex-payload/src/plugin.rs b/crates/cpex-payload/src/plugin.rs new file mode 100644 index 00000000..dd743a24 --- /dev/null +++ b/crates/cpex-payload/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-payload/src/plugins/identity_checker.rs b/crates/cpex-payload/src/plugins/identity_checker.rs new file mode 100644 index 00000000..59e1450e --- /dev/null +++ b/crates/cpex-payload/src/plugins/identity_checker.rs @@ -0,0 +1,75 @@ +use crate::cmf::message::MessagePayload; +use crate::context::PluginContext; +use crate::error::PluginViolation; +use crate::extensions::Extensions; +use crate::hooks::PluginResult; + +pub fn identity_check(payload: &MessagePayload, extensions: &Extensions, _ctx: &PluginContext) -> PluginResult { + let is_result = payload.message.is_tool_result(); + + if is_result { + 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 { + 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() +} diff --git a/crates/cpex-payload/src/plugins/mod.rs b/crates/cpex-payload/src/plugins/mod.rs new file mode 100644 index 00000000..7ec351ed --- /dev/null +++ b/crates/cpex-payload/src/plugins/mod.rs @@ -0,0 +1 @@ +pub mod identity_checker; \ No newline at end of file From 7fde6ca8cee0f89452aa8cc2280658229de0d2b1 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Tue, 9 Jun 2026 14:47:02 -0400 Subject: [PATCH 11/11] Updated documentation on usage Signed-off-by: Shriti Priya --- crates/cpex-wasm-host/README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/cpex-wasm-host/README.md b/crates/cpex-wasm-host/README.md index 9753358d..f2fc15e9 100644 --- a/crates/cpex-wasm-host/README.md +++ b/crates/cpex-wasm-host/README.md @@ -177,20 +177,6 @@ cargo test ## Usage -### Direct (without PluginManager) - -```rust -use std::path::Path; -use cpex_wasm_host::policy_loader::SandboxPolicy; -use cpex_wasm_host::sandbox_manager::SandboxManager; - -let policy = SandboxPolicy::default(); // deny-all sandbox -let mut manager = SandboxManager::new()?; -manager.load_wasmplugin(Path::new("wasm/plugin.wasm"), Some(&policy)).await?; - -let result = manager.invoke(payload, extensions, ctx).await?; -``` - ### Via PluginManager ```rust