diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8842547e..f9127535 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 + - name: WIT consistency check + run: make -C crates/cpex-wasm-host check-wit - name: rustfmt run: cargo fmt --all -- --check - name: clippy diff --git a/.gitignore b/.gitignore index 88099657..18e0d184 100644 --- a/.gitignore +++ b/.gitignore @@ -277,3 +277,6 @@ tmp/ plugin-catalog # Hugo build output docs/public/ +crates/cpex-wasm-host/wasm/*.wasm +crates/cpex-wasm-plugin/plugin.wasm +crates/cpex-wasm-plugin/Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index 9a49b7d6..77d589bf 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 = "aho-corasick" version = "1.1.4" @@ -17,6 +26,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" @@ -26,6 +41,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "antlr4rust" version = "0.5.2" @@ -45,9 +72,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "apl-cmf" @@ -71,7 +98,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] @@ -88,7 +115,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] @@ -99,9 +126,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" 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.2" @@ -194,18 +227,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", ] [[package]] @@ -222,9 +255,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -233,14 +266,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -344,7 +378,7 @@ dependencies = [ "pkcs8 0.9.0", "prost 0.10.4", "prost-types 0.10.1", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "regex", "serde_json", @@ -398,9 +432,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -447,7 +481,7 @@ dependencies = [ "log", "num", "pin-project-lite", - "rand 0.9.4", + "rand 0.9.5", "rustls", "rustls-native-certs", "rustls-pemfile", @@ -457,7 +491,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-stream", "tokio-util", @@ -499,9 +533,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" dependencies = [ "bytes", "cfg_aliases", @@ -521,6 +555,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "byteorder" @@ -530,15 +567,89 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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.59.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +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.59.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 = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.65" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "jobserver", @@ -563,7 +674,7 @@ dependencies = [ "serde_json", "serde_with", "smol_str", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -590,7 +701,7 @@ dependencies = [ "serde_with", "smol_str", "stacker", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-security", ] @@ -633,9 +744,20 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] [[package]] name = "chrono" @@ -657,6 +779,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -666,6 +840,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.19", +] + [[package]] name = "colored" version = "3.1.1" @@ -761,7 +944,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", "tracing", @@ -818,7 +1001,7 @@ dependencies = [ "serde_json", "serde_yaml", "stacker", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] @@ -833,7 +1016,7 @@ dependencies = [ "cel", "cpex-core", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] @@ -907,7 +1090,7 @@ dependencies = [ "futures", "jsonwebtoken", "mockito", - "rand 0.8.6", + "rand 0.8.7", "reqwest", "rsa", "serde", @@ -963,7 +1146,7 @@ dependencies = [ "sha2 0.10.9", "testcontainers", "testcontainers-modules", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "url", @@ -984,202 +1167,485 @@ dependencies = [ ] [[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +name = "cpex-wasm-host" +version = "0.2.2" dependencies = [ - "libc", + "anyhow", + "async-trait", + "chrono", + "cpex-core", + "criterion", + "hyper", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "tracing", + "tracing-subscriber", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-http", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "cpp_demangle" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] [[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", + "libc", ] [[package]] -name = "crypto-common" -version = "0.1.6" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "generic-array", - "typenum", + "libc", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "cranelift-assembler-x64" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "3d521bdbc6098937af83ef4ab6d5c07398126bc71878f7ef4ea9499977978ef5" dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", + "cranelift-assembler-x64-meta", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "cranelift-assembler-x64-meta" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +checksum = "3dde0b83164d4a497860af4236271178bc640512b067101e0853e4f74eaa4df5" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", + "cranelift-srcgen", ] [[package]] -name = "darling" -version = "0.23.0" +name = "cranelift-bforest" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "0111d110b72b4efad69a372e29e21628652fd0bcab66967e5c8350ab679affd5" dependencies = [ - "darling_core", - "darling_macro", + "cranelift-entity", + "wasmtime-internal-core", ] [[package]] -name = "darling_core" -version = "0.23.0" +name = "cranelift-bitset" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "cf01ecc92fc5499789d79c3b817299d5a8ddd828d31dcf0f9cc3cc66f38dbb36" dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.118", + "serde", + "serde_derive", + "wasmtime-internal-core", ] [[package]] -name = "darling_macro" -version = "0.23.0" +name = "cranelift-codegen" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "6cd2563bead0090c3879a7ff7327f7550c9d59bb5d05ecc8cd7886977aa3125a" dependencies = [ - "darling_core", - "quote", - "syn 2.0.118", + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", ] [[package]] -name = "deadpool" -version = "0.13.0" +name = "cranelift-codegen-meta" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +checksum = "9640d250d26f9381a73dc7f9862b27928d4809119aec73be0e556612e67b6a99" dependencies = [ - "deadpool-runtime", - "num_cpus", - "tokio", + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", ] [[package]] -name = "deadpool-redis" -version = "0.23.0" +name = "cranelift-codegen-shared" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bafa30c49dafe086d10116074e422ad7fc1c3cf554697e744a3ab112599ebd09" -dependencies = [ - "deadpool", - "redis", -] +checksum = "a07f156b90efc94371ddb3536f76e4ab671ad2e093bfa5511e10198cadbb0c47" [[package]] -name = "deadpool-runtime" -version = "0.3.1" +name = "cranelift-control" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +checksum = "d75a76fd9dd37dcbc3d2d2e00abe3281a7e88adc035fba3b8114cc69981576ec" dependencies = [ - "tokio", + "arbitrary", ] [[package]] -name = "der" -version = "0.6.1" +name = "cranelift-entity" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +checksum = "2eea22522144ba08c7e7ef94bfc25d474ef016c2771974b8ab1986734ac85965" dependencies = [ - "const-oid", + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", ] [[package]] -name = "der" -version = "0.7.10" +name = "cranelift-frontend" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "efa2826c80dff1d93b19b3cfbcaf9fae44c78d739a98d146dd5bd89c51e14367" dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "cranelift-isle" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] +checksum = "e238a69b95c5415456f22189494a12db94f313bb72b6ec9cc88ddf2f1056e28e" [[package]] -name = "digest" -version = "0.9.0" +name = "cranelift-native" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +checksum = "eceb0ebd8d6aef6bb287e8d532a164b0db1c0062961211e9c22a91f67521c098" dependencies = [ - "generic-array", + "cranelift-codegen", + "libc", + "target-lexicon", ] [[package]] -name = "digest" -version = "0.10.7" +name = "cranelift-srcgen" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid", - "crypto-common", - "subtle", -] +checksum = "004643f39a7bec553de5263d650db30e5b9caec1d5cbe065fd732b7ec9ae40d0" [[package]] -name = "displaydoc" -version = "0.2.6" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", + "cfg-if", ] [[package]] -name = "docker_credential" -version = "1.4.0" +name = "criterion" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" dependencies = [ - "base64 0.22.1", - "serde", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "futures", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[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.119", +] + +[[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.119", +] + +[[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.119", +] + +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-redis" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafa30c49dafe086d10116074e422ad7fc1c3cf554697e744a3ab112599ebd09" +dependencies = [ + "deadpool", + "redis", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[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", + "crypto-common", + "subtle", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", "serde_json", ] @@ -1244,7 +1710,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1274,6 +1740,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" @@ -1283,6 +1761,15 @@ 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.4.1" @@ -1300,7 +1787,7 @@ checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1352,9 +1839,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ferroid" @@ -1363,7 +1850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" dependencies = [ "portable-atomic", - "rand 0.9.4", + "rand 0.9.5", "web-time", ] @@ -1401,7 +1888,13 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" -version = "0.5.7" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" @@ -1426,6 +1919,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.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1434,9 +1938,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1449,9 +1953,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1459,15 +1963,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1476,38 +1980,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1520,6 +2024,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.9" @@ -1551,11 +2069,9 @@ 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]] @@ -1565,8 +2081,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "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]] @@ -1599,12 +2130,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1614,6 +2162,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash", + "serde", + "serde_core", ] [[package]] @@ -1664,9 +2214,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1674,9 +2224,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1699,9 +2249,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1721,9 +2271,9 @@ dependencies = [ [[package]] name = "hyper-named-pipe" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" dependencies = [ "hex", "hyper", @@ -1731,7 +2281,6 @@ dependencies = [ "pin-project-lite", "tokio", "tower-service", - "winapi", ] [[package]] @@ -1747,7 +2296,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -1907,6 +2456,12 @@ dependencies = [ "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" @@ -1957,12 +2512,39 @@ 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.59.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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "itertools" version = "0.10.5" @@ -1987,13 +2569,33 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "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 = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -2032,7 +2634,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2046,7 +2648,7 @@ dependencies = [ "ena", "itertools 0.14.0", "lalrpop-util", - "petgraph", + "petgraph 0.7.1", "pico-args", "regex", "regex-syntax", @@ -2076,11 +2678,23 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[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" +version = "0.2.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" [[package]] name = "libm" @@ -2088,6 +2702,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -2153,7 +2776,7 @@ dependencies = [ "quote", "regex-automata", "regex-syntax", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2171,17 +2794,50 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[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.2" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memfd" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] [[package]] name = "miette" @@ -2203,7 +2859,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2220,9 +2876,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -2246,7 +2902,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand 0.9.4", + "rand 0.9.5", "regex", "serde_json", "serde_urlencoded", @@ -2288,6 +2944,15 @@ dependencies = [ "serde", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -2304,9 +2969,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2323,7 +2988,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -2354,11 +3019,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2403,12 +3067,30 @@ 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.1", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2484,7 +3166,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2518,13 +3200,23 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[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", ] @@ -2560,7 +3252,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2600,11 +3292,57 @@ dependencies = [ "spki 0.7.3", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "postcard" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] [[package]] name = "potential_utf" @@ -2675,14 +3413,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2730,7 +3468,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2762,6 +3500,29 @@ dependencies = [ "cc", ] +[[package]] +name = "pulley-interpreter" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04adf8f93264685f2c70a3edb8f828c791e71b22659253319c33f29d1165aef" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c120a6af1a574a42efcd9df2ad27cb78bc04118c5e0c69e90599f17301a1c7a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pyo3" version = "0.29.0" @@ -2818,7 +3579,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2830,7 +3591,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2847,7 +3608,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -2855,20 +3616,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -2876,23 +3638,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2911,9 +3673,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2922,14 +3684,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2968,11 +3741,46 @@ 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 = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[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 = "redis" -version = "1.3.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fa6f8e4b491d7a8ef3a9550a4d71969bd0064f46e32b8dbbcc7fc60dad94fed" +checksum = "b0b9503711b03773e43b31668c7b5bd279ee7cd9b7d18cff7c23a42cc1d08e5a" dependencies = [ "arc-swap", "arcstr", @@ -3006,43 +3814,68 @@ 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" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", ] [[package]] -name = "regex" -version = "1.12.4" +name = "regalloc2" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3090,7 +3923,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -3156,11 +3989,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc-literal-escaper" @@ -3190,11 +4029,21 @@ dependencies = [ "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.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -3249,9 +4098,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -3350,12 +4199,16 @@ 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" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3373,29 +4226,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap 2.14.0", "itoa", @@ -3407,13 +4260,22 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", ] [[package]] @@ -3457,7 +4319,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3491,7 +4353,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -3503,7 +4365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3517,6 +4379,15 @@ dependencies = [ "keccak", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3557,7 +4428,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", ] @@ -3578,6 +4449,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "smol_str" @@ -3591,9 +4465,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3601,9 +4475,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "spki" @@ -3670,7 +4544,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3681,7 +4555,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3703,9 +4577,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" dependencies = [ "proc-macro2", "quote", @@ -3729,7 +4614,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3738,6 +4623,19 @@ 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.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "term" version = "1.2.1" @@ -3747,6 +4645,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 = "testcontainers" version = "0.26.3" @@ -3770,7 +4677,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-stream", "tokio-util", @@ -3797,11 +4704,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -3812,25 +4719,34 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", ] [[package]] name = "time" -version = "0.3.53" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", "num-conv", @@ -3848,9 +4764,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3878,11 +4794,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3895,9 +4821,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3912,13 +4838,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3944,18 +4870,58 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", "futures-util", + "libc", "pin-project-lite", "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", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[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]] +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 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tonic" version = "0.14.6" @@ -4064,7 +5030,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4074,6 +5040,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4177,147 +5173,579 @@ dependencies = [ ] [[package]] -name = "ureq-proto" -version = "0.6.0" +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +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.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.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" +dependencies = [ + "leb128fmt", + "wasmparser 0.254.0", +] + +[[package]] +name = "wasmparser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a83ef84ac8bab735c89e27b0268b0586099fbe81281ae45be2b8e4f407b2daa" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439686d3deb38525c86b88bbc90f7ba669f70c8edc7d6b35147c738bbef5ff76" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2799e6c35393c2a38999f13e4c80ab5d5d9756082bb554cbd1f60485a18293" +dependencies = [ + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81d2c5850fb8c448792453765d97f6f01096a32708f3c36798e86220763c90c0" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed79c4e9ab416dcc5e46b9d1ec9b7c2e3c730deab525996b0de45a35fc8b2c3" + +[[package]] +name = "wasmtime-internal-core" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3073c03f97f871fe6400e68621c863b93ba79296f8e570285231952d23fcc804" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "7a15981261d361f9c93b42929f446b4009840853ceea181ad6e17730f4016a0b" dependencies = [ - "base64 0.22.1", - "http", - "httparse", + "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.19", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", ] [[package]] -name = "url" -version = "2.5.8" +name = "wasmtime-internal-fiber" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "33aed9d91d54c9f861ba7e3c3130bded8a7a63e01fe58c3a9a36b45d6ee0fb57" dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", + "cc", + "cfg-if", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", ] [[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - -[[package]] -name = "utf8_iter" -version = "1.0.4" +name = "wasmtime-internal-jit-debug" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "944942118aab67e7b4febfe88a65d0af4cfd10af8ed0e79fd6cf39d75359ab7e" +dependencies = [ + "cc", + "object 0.39.1", + "rustix", + "wasmtime-internal-versioned-export-macros", +] [[package]] -name = "uuid" -version = "1.23.4" +name = "wasmtime-internal-jit-icache-coherence" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "37dee05e8c35759826f6b926cd949c51f771b6a58619d8e60c63c3f3d3e5e59b" dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", ] [[package]] -name = "version_check" -version = "0.9.5" +name = "wasmtime-internal-unwinder" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "a83f7ebe911a6f1605ff0d3a678472ddb1fcc57c3e2f6bae5dd4d170b625b3ed" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object 0.39.1", + "wasmtime-environ", +] [[package]] -name = "walkdir" -version = "2.5.0" +name = "wasmtime-internal-versioned-export-macros" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +checksum = "d75980644456dc003238766254b0e5f002704630237ccd0728747991fe2739d7" dependencies = [ - "same-file", - "winapi-util", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "want" -version = "0.3.1" +name = "wasmtime-internal-winch" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "81af36995b4720b32e3d667541b548a27184a859d09a6ee745eaba095f5a6f6e" dependencies = [ - "try-lock", + "cranelift-codegen", + "gimli", + "log", + "object 0.39.1", + "target-lexicon", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", ] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "wasmtime-internal-wit-bindgen" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "ada06667b7948892703fcc9f0b9b337c2e78c334d74d4fa62e2f75f07fbb3659" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap 2.14.0", + "wit-parser", +] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "wasmtime-wasi" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "fd8e9db77f7b60f4c5f6f72847f55eb646ed7ed2f68e362559ee07dc543fb408" dependencies = [ - "wit-bindgen", + "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.2", + "rustix", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", ] [[package]] -name = "wasm-bindgen" -version = "0.2.126" +name = "wasmtime-wasi-http" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "c3b7f2587ebe31df3d143baebe3aa9f6712955507e15778fe00097eb77da9ebe" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "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 = "wasm-bindgen-futures" -version = "0.4.76" +name = "wasmtime-wasi-io" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "3805f02be91374a4fd02c0f947daf07bbaa6fbebb5909de5305fa5b0e9568f30" dependencies = [ - "js-sys", - "wasm-bindgen", + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" +name = "wast" +version = "35.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "leb128", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" +name = "wast" +version = "254.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32" dependencies = [ "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.118", - "wasm-bindgen-shared", + "leb128fmt", + "memchr", + "unicode-width 0.2.2", + "wasm-encoder 0.254.0", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" +name = "wat" +version = "1.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c" dependencies = [ - "unicode-ident", + "wast 254.0.0", ] [[package]] @@ -4342,13 +5770,62 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wiggle" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aca130e25a57e29bbb541441b6e261970a8469580bffebe904d96f0df67e41d" +dependencies = [ + "bitflags", + "thiserror 2.0.19", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f0a45b47e893521cad81819f2e0db18a8b05086fd4bfb35369ed8830a8f0148" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e297e0325cffa6d368386b6e672e9d6c6a7ad34bdb8a5f49319db4decef2a990" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "wiggle-generate", +] + [[package]] name = "wildmatch" version = "2.6.1" @@ -4386,6 +5863,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73bc25d27222248f9bafc7e9945f61e74275360c3ea0d34008810d436140a53" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.19", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4407,7 +5903,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4418,7 +5914,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4451,16 +5947,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4478,31 +5974,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "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 = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "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", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4511,60 +5990,30 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4572,34 +6021,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows_x86_64_gnullvm" +name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "winnow" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "winx" +version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] [[package]] name = "wit-bindgen" @@ -4607,6 +6060,37 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[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.1", + "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]] name = "writeable" version = "0.6.3" @@ -4625,9 +6109,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.16" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yoke" @@ -4648,28 +6132,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4689,7 +6173,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -4710,7 +6194,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4743,11 +6227,39 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[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 11f2bd51..ece5697a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/cpex-sdk", "crates/cpex-builtins", "crates/cpex-ffi", + "crates/cpex-wasm-host", "crates/apl-core", "crates/apl-cmf", "crates/apl-cpex", @@ -33,6 +34,9 @@ members = [ # or `maturin develop` to build this crate explicitly. "bindings/python", ] +exclude = [ + "crates/cpex-wasm-plugin", +] # `default-members` controls what `cargo build` / `cargo test` (with no # `-p` or `--workspace` flag) picks up. `cpex-session-valkey` pulls a redis diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 1f0d90aa..9ec6a188 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -20,9 +20,12 @@ keywords.workspace = true categories.workspace = true rust-version.workspace = true +[features] +default = ["runtime"] +runtime = ["dep:tokio", "dep:tokio-util", "dep:cpex-orchestration", "dep:arc-swap"] + [dependencies] -tokio = { workspace = true } -tokio-util = { workspace = true } +# --- Always-on dependencies (WASM-compatible) --- serde = { workspace = true } serde_yaml = { workspace = true } serde_json = { workspace = true } @@ -31,17 +34,15 @@ thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } hashbrown = { workspace = true } -arc-swap = { workspace = true } wildmatch = { workspace = true } chrono = { workspace = true } -# Zeroizing wrapper for raw credential material in RawCredentialsExtension. -# `derive` feature pulls the proc-macro so we can `#[derive(Zeroize)]` on -# token-bearing structs in a future slice; for now only the -# `Zeroizing` wrapper is used directly. zeroize = { version = "1.8", features = ["zeroize_derive"] } -# Shared concurrency primitive used by `executor::run_concurrent_phase` -# (and apl-core's `Effect::Parallel`). Leaf crate, no cycles back here. -cpex-orchestration = { workspace = true } + +# --- Runtime-only dependencies (require tokio, not WASM-compatible) --- +tokio = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } +arc-swap = { workspace = true, optional = true } +cpex-orchestration = { workspace = true, optional = true } [lints] workspace = true diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs index 4eb7678c..68a5d2db 100644 --- a/crates/cpex-core/src/cmf/message.rs +++ b/crates/cpex-core/src/cmf/message.rs @@ -219,6 +219,7 @@ pub struct MessagePayload { } crate::impl_plugin_payload!(MessagePayload); +crate::impl_wasm_payload!(MessagePayload, "cmf.message"); crate::define_hook! { /// CMF message evaluation hook. diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 30adf128..a6ced4a5 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -19,6 +19,7 @@ // enabled, conditions on individual plugins are ignored. use std::collections::{HashMap, HashSet}; +#[cfg(feature = "runtime")] use std::path::Path; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -568,6 +569,11 @@ impl StringOrList { } } +// --------------------------------------------------------------------------- +// Config Loading +// --------------------------------------------------------------------------- + +#[cfg(feature = "runtime")] /// 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 { diff --git a/crates/cpex-core/src/delegation/payload.rs b/crates/cpex-core/src/delegation/payload.rs index f2a9bc44..4f0a9e37 100644 --- a/crates/cpex-core/src/delegation/payload.rs +++ b/crates/cpex-core/src/delegation/payload.rs @@ -56,6 +56,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; +#[cfg(feature = "runtime")] use crate::executor::PipelineResult; use crate::extensions::raw_credentials::DelegationMode; use crate::extensions::{ @@ -348,6 +349,9 @@ impl DelegationPayload { } } + // -------- Host-side application helpers -------- + + #[cfg(feature = "runtime")] /// 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 @@ -438,6 +442,13 @@ impl DelegationPayload { impl_plugin_payload!(DelegationPayload); +// WASM transport: `bearer_token` and `delegated_token.token` are +// `#[serde(skip)]`, so raw credential material never crosses the +// sandbox boundary. A WASM handler can attenuate scopes and populate +// `delegation_update` / `metadata`, but token minting that must +// return the raw token stays in-process. +crate::impl_wasm_payload!(DelegationPayload, "cpex.delegation"); + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cpex-core/src/elicitation/payload.rs b/crates/cpex-core/src/elicitation/payload.rs index 5c7f00d3..af48f5e7 100644 --- a/crates/cpex-core/src/elicitation/payload.rs +++ b/crates/cpex-core/src/elicitation/payload.rs @@ -55,6 +55,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +#[cfg(feature = "runtime")] use crate::executor::PipelineResult; use crate::impl_plugin_payload; @@ -266,6 +267,9 @@ impl ElicitationPayload { self.channel.as_deref() } + // -------- Host-side application helper -------- + + #[cfg(feature = "runtime")] /// Pull the resolved `ElicitationPayload` out of a `PipelineResult` /// returned by `mgr.invoke_entries::(...)`. Returns /// `None` when the pipeline was denied or the result's payload wasn't diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs index 8ad5115e..499ee3c9 100644 --- a/crates/cpex-core/src/hooks/mod.rs +++ b/crates/cpex-core/src/hooks/mod.rs @@ -16,18 +16,21 @@ // // Hook types are open — hosts define their own using define_hook! alongside the built-ins. -pub mod adapter; pub mod macros; pub mod metadata; pub mod payload; pub mod trait_def; pub mod types; +#[cfg(feature = "runtime")] +pub mod adapter; + // Re-export core types at the hooks level +#[cfg(feature = "runtime")] pub use adapter::TypedHandlerAdapter; pub use metadata::{ lookup as lookup_hook_metadata, register_hook_metadata, HookMetadata, HookPhase, }; -pub use payload::{Extensions, PluginPayload}; +pub use payload::{Extensions, PluginPayload, WasmSerializablePayload}; 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 ef3e6950..f4d7a4be 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -123,3 +123,81 @@ macro_rules! impl_plugin_payload { } }; } + +// --------------------------------------------------------------------------- +// WasmSerializablePayload — opt-in WASM transport trait +// --------------------------------------------------------------------------- + +/// Opt-in trait for payload types that can cross the WASM serialization boundary. +/// +/// Implement this (via [`impl_wasm_payload!`]) for any payload type that WASM +/// plugins should be able to receive or return. The type discriminator string +/// is embedded in the WIT `custom-payload` record so the host and guest can +/// agree on which concrete type to deserialize. +/// +/// # Example +/// +/// ``` +/// use serde::{Deserialize, Serialize}; +/// use cpex_core::{impl_plugin_payload, impl_wasm_payload}; +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] +/// struct ToolInvokePayload { +/// tool_name: String, +/// user: String, +/// } +/// +/// impl_plugin_payload!(ToolInvokePayload); +/// impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); +/// ``` +pub trait WasmSerializablePayload: PluginPayload { + /// Type discriminator used in the WIT `custom-payload` record. + /// + /// Must be unique across all payload types registered with the host. + /// Convention: `"."` (e.g. `"cmf.message"`, `"cpex.tool_invoke"`). + fn payload_type_name() -> &'static str + where + Self: Sized; + + /// Serialize this payload to JSON bytes for WASM transport. + fn to_wasm_bytes(&self) -> Result, serde_json::Error>; + + /// Deserialize a payload from JSON bytes received from WASM. + fn from_wasm_bytes(bytes: &[u8]) -> Result + where + Self: Sized; +} + +/// Implements [`WasmSerializablePayload`] for a type that is `Serialize + Deserialize`. +/// +/// The type must already implement [`PluginPayload`] (via [`impl_plugin_payload!`]). +/// +/// ``` +/// use serde::{Deserialize, Serialize}; +/// use cpex_core::{impl_plugin_payload, impl_wasm_payload}; +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] +/// struct ToolInvokePayload { +/// tool_name: String, +/// user: String, +/// } +/// +/// impl_plugin_payload!(ToolInvokePayload); +/// impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); +/// ``` +#[macro_export] +macro_rules! impl_wasm_payload { + ($ty:ty, $name:literal) => { + impl $crate::hooks::payload::WasmSerializablePayload for $ty { + fn payload_type_name() -> &'static str { + $name + } + fn to_wasm_bytes(&self) -> Result, serde_json::Error> { + serde_json::to_vec(self) + } + fn from_wasm_bytes(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + } + } + }; +} diff --git a/crates/cpex-core/src/identity/payload.rs b/crates/cpex-core/src/identity/payload.rs index 4048f1d4..42d881ef 100644 --- a/crates/cpex-core/src/identity/payload.rs +++ b/crates/cpex-core/src/identity/payload.rs @@ -63,6 +63,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; +#[cfg(feature = "runtime")] use crate::executor::PipelineResult; use crate::extensions::{ ClientExtension, DelegationExtension, Extensions, RawCredentialsExtension, SecurityExtension, @@ -275,6 +276,9 @@ impl IdentityPayload { } } + // -------- Host-side application helpers -------- + + #[cfg(feature = "runtime")] /// Pull the resolved `IdentityPayload` out of a `PipelineResult` /// returned by `mgr.invoke_named::(...)`. Returns /// `None` when the pipeline was denied (no `modified_payload`) @@ -357,6 +361,11 @@ impl IdentityPayload { impl_plugin_payload!(IdentityPayload); +// WASM transport: `raw_token` is `#[serde(skip)]`, so a WASM handler +// receives every input except the raw credential bytes — it resolves +// identity from `headers` / claims and returns the output fields. +crate::impl_wasm_payload!(IdentityPayload, "cpex.identity"); + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index fae7bd25..24a29aee 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -34,12 +34,20 @@ pub mod context; pub mod delegation; pub mod elicitation; pub mod error; -pub mod executor; pub mod extensions; -pub mod factory; pub mod hooks; pub mod identity; -pub mod manager; pub mod plugin; + +// Runtime-only modules — require tokio, task spawning, orchestration. +// Excluded when building for WASM targets (use `default-features = false`). +#[cfg(feature = "runtime")] +pub mod executor; +#[cfg(feature = "runtime")] +pub mod factory; +#[cfg(feature = "runtime")] +pub mod manager; +#[cfg(feature = "runtime")] pub mod registry; +#[cfg(feature = "runtime")] pub mod visitor; diff --git a/crates/cpex-wasm-host/Cargo.toml b/crates/cpex-wasm-host/Cargo.toml new file mode 100644 index 00000000..4c146613 --- /dev/null +++ b/crates/cpex-wasm-host/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "cpex-wasm-host" +version = "0.2.2" +edition = "2021" +rust-version = "1.96" +description = "WASM plugin host runtime for the CPEX framework — sandboxed wasmtime execution with capability-based access control" +license = "Apache-2.0" +authors = ["Shriti Priya"] +repository = "https://github.com/contextforge/contextforge-plugins-framework" +homepage = "https://github.com/contextforge/contextforge-plugins-framework" +keywords = ["wasm", "plugin", "sandbox", "wasmtime", "cpex"] +categories = ["wasm", "development-tools"] +exclude = ["wasm/", "examples/data/", "benchmarking/performance_comparison.png"] + +[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_json = { workspace = true } +cpex-core = { workspace = true } +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +tracing = { workspace = true } + +[dev-dependencies] +criterion = { version = "0.5", features = ["async_tokio"] } +async-trait = "0.1" +serde_yaml = "0.9" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } + +[[bench]] +name = "invocation" +harness = false +path = "benchmarking/invocation.rs" + +[[bench]] +name = "comprehensive" +harness = false +path = "benchmarking/comprehensive.rs" diff --git a/crates/cpex-wasm-host/Makefile b/crates/cpex-wasm-host/Makefile new file mode 100644 index 00000000..f784c769 --- /dev/null +++ b/crates/cpex-wasm-host/Makefile @@ -0,0 +1,175 @@ +.PHONY: all build-examples build-all-plugins build-test-plugins build-bench-plugins \ + run-all-demos run-plugin-demo run-capabilities-demo run-sandbox-demo run-env-demo \ + run-token-attenuator-demo run-network-policy-demo run-resource-limits-demo setup-sandbox-demo-data \ + bench-all test unit-tests integration-tests plugin-tests clippy \ + clean clean-all sync-wit check-wit help + +PLUGIN_DIR := ../cpex-wasm-plugin +WASM_DIR := wasm +PLUGIN_TARGET := $(PLUGIN_DIR)/target/wasm32-wasip2/release/cpex_wasm_plugin.wasm + +# Plugin lists — single source of truth for what gets built and staged to wasm/. +DEMO_PLUGINS := identity-checker header-injector audit-logger token-attenuator \ + tool-invoke-checker pii-guard audit-logger-custom remote-authz +TEST_PLUGINS := fs-test net-test env-test noop fs-sandbox-demo env-sandbox-demo \ + resource-test net-http-test +BENCH_PLUGINS := compute-bench + +# --------------------------------------------------------------------------- +# Internal helper — build a list of plugins and stage each to wasm/. +# --------------------------------------------------------------------------- +define build-plugins + @mkdir -p $(WASM_DIR) + @for plugin in $(1); do \ + echo "Building $$plugin..."; \ + cargo build --manifest-path $(PLUGIN_DIR)/Cargo.toml \ + --target wasm32-wasip2 --release \ + --features $$plugin --no-default-features; \ + cp $(PLUGIN_TARGET) $(WASM_DIR)/$$plugin.wasm; \ + done + @echo "Done: $(2)" +endef + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- + +all: build-all-plugins build-test-plugins build-examples ## Build all plugins and host examples (default) + +build-examples: ## Compile all host example binaries + cargo build --examples + +build-all-plugins: ## Build demo WASM plugins and stage to wasm/ + $(call build-plugins,$(DEMO_PLUGINS),All demo plugins built and staged.) + +build-test-plugins: ## Build test WASM plugins and stage to wasm/ + $(call build-plugins,$(TEST_PLUGINS),All test plugins built and staged.) + +build-bench-plugins: ## Build benchmark WASM plugins and stage to wasm/ + $(call build-plugins,$(BENCH_PLUGINS),Benchmark plugins built and staged.) + +# --------------------------------------------------------------------------- +# Run demos +# --------------------------------------------------------------------------- + +run-all-demos: build-all-plugins build-test-plugins setup-sandbox-demo-data build-examples ## Build all plugins and run ALL demos end-to-end + @echo "\n========== Plugin Demo ==========\n" + cargo run --example wasm_plugin_demo + @echo "\n========== Capabilities Demo ==========\n" + cargo run --example wasm_capabilities_demo + @echo "\n========== Filesystem Sandbox Demo ==========\n" + cargo run --example wasm_fs_sandbox_demo + @echo "\n========== Env Sandbox Demo ==========\n" + cargo run --example wasm_env_sandbox_demo + @echo "\n========== Token Attenuator Demo ==========\n" + cargo run --example wasm_token_attenuator_demo + @echo "\n========== Network Policy Demo ==========\n" + cargo run --example wasm_network_policy_demo + @echo "\n========== Resource Limits Demo ==========\n" + cargo run --example wasm_resource_limits_demo + @echo "\n========== All demos passed ==========\n" + +run-plugin-demo: build-all-plugins build-examples ## Run the custom-payload plugin demo + cargo run --example wasm_plugin_demo + +run-capabilities-demo: build-all-plugins build-examples ## Run the capabilities isolation demo + cargo run --example wasm_capabilities_demo + +setup-sandbox-demo-data: ## Create examples/data subdirs and seed files for the fs sandbox demo + @mkdir -p examples/data examples/data/rules examples/data/cache examples/data/audit \ + examples/data/counters examples/data/plugins examples/data/scratch + @echo 'allow_tool: "*"' > examples/data/rules/policy.yaml + @echo '0' > examples/data/counters/rate.txt + @touch examples/data/plugins/fs-sandbox-demo.wasm + @rm -rf examples/data/audit/events examples/data/scratch/work examples/data/counters/new + @echo "Sandbox demo data ready under examples/data/" + +run-sandbox-demo: build-test-plugins setup-sandbox-demo-data build-examples ## Run the filesystem sandbox permissions demo + cargo run --example wasm_fs_sandbox_demo + +run-env-demo: build-test-plugins build-examples ## Run the env variable sandbox permissions demo + cargo run --example wasm_env_sandbox_demo + +run-token-attenuator-demo: build-all-plugins build-examples ## Run the token delegation demo + cargo run --example wasm_token_attenuator_demo + +run-network-policy-demo: build-test-plugins build-examples ## Run the network policy sandbox demo + cargo run --example wasm_network_policy_demo + +run-resource-limits-demo: build-test-plugins build-examples ## Run the resource limits sandbox demo + cargo run --example wasm_resource_limits_demo + +# --------------------------------------------------------------------------- +# Benchmarks +# --------------------------------------------------------------------------- + +bench-all: build-all-plugins build-bench-plugins build-test-plugins ## Build all plugins and run benchmarks + generate chart + cargo bench + @echo "\n========== Generating performance chart ==========\n" + cd benchmarking && python3 plot_results.py + @echo "\n========== Benchmarks complete ==========\n" + +# --------------------------------------------------------------------------- +# Test +# --------------------------------------------------------------------------- + +test: clean-all build-all-plugins build-test-plugins ## Clean, rebuild all plugins, run all tests + @echo "\n========== Running host unit tests ==========\n" + cargo test + @echo "\n========== Running host integration tests ==========\n" + cargo test --tests -- --ignored + @echo "\n========== Running plugin unit tests ==========\n" + @for plugin in $(DEMO_PLUGINS) $(TEST_PLUGINS); do \ + echo "Testing $$plugin..."; \ + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml \ + --features $$plugin --no-default-features || exit 1; \ + done + @echo "\n========== All tests passed ==========\n" + +unit-tests: ## Run host unit tests (no WASM plugins needed) + cargo test + +integration-tests: build-all-plugins build-test-plugins ## Run host integration tests (builds plugins first) + cargo test --tests -- --ignored + +plugin-tests: ## Run plugin crate unit tests (all demos) + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml --features test-demos + +clippy: ## Run clippy on both host and plugin crates + cargo clippy -- -D warnings + cargo clippy --manifest-path $(PLUGIN_DIR)/Cargo.toml --target wasm32-wasip2 -- -D warnings + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +clean: ## Remove host build artifacts and wasm/ binaries + cargo clean + rm -f $(WASM_DIR)/*.wasm + +clean-all: ## Remove host + plugin build artifacts and ALL .wasm files + cargo clean + cargo clean --manifest-path $(PLUGIN_DIR)/Cargo.toml + cargo clean --manifest-path $(PLUGIN_DIR)/Cargo.toml --target wasm32-wasip2 + rm -f $(WASM_DIR)/*.wasm + +# --------------------------------------------------------------------------- +# WIT sync — cpex-wasm-plugin is the source of truth +# --------------------------------------------------------------------------- + +sync-wit: ## Copy wit/world.wit from cpex-wasm-plugin (updating the location comment) + @sed '1s|cpex-wasm-plugin|cpex-wasm-host|' $(PLUGIN_DIR)/wit/world.wit > wit/world.wit + @echo "wit/world.wit synced from cpex-wasm-plugin" + +check-wit: ## Fail if wit/world.wit has drifted from cpex-wasm-plugin + @sed '1s|cpex-wasm-plugin|cpex-wasm-host|' $(PLUGIN_DIR)/wit/world.wit | diff - wit/world.wit \ + || { echo "ERROR: wit/world.wit is out of sync — run 'make sync-wit'"; exit 1; } + @echo "wit/world.wit is in sync" + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-24s\033[0m %s\n", $$1, $$2}' diff --git a/crates/cpex-wasm-host/README.md b/crates/cpex-wasm-host/README.md new file mode 100644 index 00000000..27d1bc16 --- /dev/null +++ b/crates/cpex-wasm-host/README.md @@ -0,0 +1,819 @@ +# cpex-wasm-host + +Run plugins in sandboxed WebAssembly instead of trusting them with full process access. This crate loads `.wasm` plugin binaries, enforces resource limits and capabilities, and plugs into the same `PluginManager` pipeline as native plugins — no changes to your invocation code. + +--- + +## Table of Contents + +1. [What Is This?](#what-is-this) +2. [Prerequisites](#prerequisites) +3. [Quick Start (See It Work in 2 Minutes)](#quick-start-see-it-work-in-2-minutes) +4. [How It Works](#how-it-works) +5. [Using WASM Plugins in Your Code](#using-wasm-plugins-in-your-code) +6. [Using Custom Payload Types](#using-custom-payload-types) +7. [Configuring Plugins (YAML)](#configuring-plugins-yaml) +8. [Running the Demos](#running-the-demos) +9. [Filesystem Sandbox Permissions Demo](#filesystem-sandbox-permissions-demo) +10. [Environment Variable Sandbox Demo](#environment-variable-sandbox-demo) +12. [Running Tests](#running-tests) +13. [Performance](#performance) +14. [Security Model](#security-model) +15. [Error Handling](#error-handling) +16. [API Reference](#api-reference) +17. [Project Structure](#project-structure) +18. [Troubleshooting](#troubleshooting) +19. [Known Limitations & Future Work](#known-limitations--future-work) + - [Raw Socket Capabilities (Future Extension)](#raw-socket-capabilities-future-extension) + +--- + +## What Is This? + +CPEX plugins inspect and modify requests flowing through your system (tool calls, LLM messages, identity checks). Normally these plugins run as native Rust code in the same process — fast but fully trusted. + +**cpex-wasm-host** lets you run plugins in a **WebAssembly sandbox** instead. Each plugin: +- Runs in isolated memory (can't read your process's data) +- Has no filesystem access unless you explicitly grant it +- Has no network access unless you explicitly allow specific hosts +- Can't exceed a CPU budget (fuel limit) or wall-clock timeout +- Can only see the extension data you declare in its capabilities + +The plugin author writes the same Rust code (same `HookHandler` trait, same `PluginResult`). The only difference is it compiles to `.wasm` instead of linking into your binary. + +--- + +## Prerequisites + +You need one extra Rust target installed: + +```bash +rustup target add wasm32-wasip2 +``` + +That's it. Everything else (wasmtime, WIT bindings) is handled by Cargo dependencies. + +--- + +## Quick Start (See It Work in 2 Minutes) + +```bash +# From the repository root: +cd crates/cpex-wasm-host + +# This builds all WASM plugins and runs both demos end-to-end: +make run-demos +``` + +You'll see output showing 4 plugins processing requests in a pipeline — identity checks, PII access control, remote authorization, and audit logging — all running inside WASM sandboxes. + +If you want to reset everything and start fresh: + +```bash +make clean-all && make run-demos +``` + +--- + +## How It Works + +``` +Your Code + │ + ▼ +PluginManager::invoke::(payload, extensions, context) + │ + ▼ (same API whether plugin is native or WASM) +┌─────────────────────────────────────────────────────┐ +│ WasmBridgeHandler │ +│ │ +│ 1. Convert payload + extensions → WIT types │ +│ 2. Reset fuel + timeout for this call │ +│ 3. Call into the WASM sandbox │ +│ 4. Convert result back to native types │ +│ 5. Validate: did the plugin modify things it's │ +│ not allowed to? If so, reject the changes. │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Wasmtime Sandbox (per plugin) │ +│ │ +│ - Isolated linear memory (can't see host memory) │ +│ - Fuel budget (instruction limit per call) │ +│ - Epoch timeout (wall-clock limit per call) │ +│ - Capability-filtered extensions (only sees what │ +│ it declared) │ +│ - Network/filesystem/env access only if allowed │ +└─────────────────────────────────────────────────────┘ +``` + +From the caller's perspective, WASM and native plugins are identical. You use the same `PluginManager`, same `invoke()` call, same result handling. The sandbox is invisible to the calling code. + +--- + +## Using WASM Plugins in Your Code + +### Step 1: Add the dependency + +```toml +[dependencies] +cpex-wasm-host = { path = "../cpex-wasm-host" } +cpex-core = { path = "../cpex-core" } +``` + +### Step 2: Register a WASM factory with the PluginManager + +```rust +use std::path::PathBuf; +use cpex_core::manager::PluginManager; +use cpex_wasm_host::factory::WasmPluginFactory; + +let mgr = PluginManager::default(); + +// Point at the directory containing your .wasm files +let wasm_dir = PathBuf::from("wasm"); + +// Register a factory for each plugin kind in your YAML config. +// "wasm://my-plugin.wasm" matches the `kind:` field in config. +mgr.register_factory( + "wasm://my-plugin.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(wasm_dir)), +); +``` + +### Step 3: Load config and invoke (same as native) + +```rust +use cpex_core::cmf::CmfHook; +use cpex_core::config::parse_config; + +// Load YAML config (declares which plugins, hooks, capabilities) +let config = parse_config(&yaml_string)?; +mgr.load_config(config)?; +mgr.initialize().await?; + +// Invoke a hook — WASM plugins participate transparently +let (result, bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload, extensions, None) + .await; + +// Check the result +if !result.continue_processing { + println!("Denied: {}", result.violation.unwrap().reason); +} + +// Wait for fire-and-forget plugins to finish +bg.wait_for_background_tasks().await; +``` + +That's it. The plugin runs in a sandbox, but your invocation code is identical to native. + +--- + +## Using Custom Payload Types + +The built-in payloads (CMF `MessagePayload`, `IdentityPayload`, `DelegationPayload`) work out of the box with `WasmPluginFactory::with_builtin_payloads()`. + +If you want to define **your own** payload type (like `ToolInvokePayload` in the demo), you need two extra steps: + +### Host side (your binary): + +```rust +use serde::{Deserialize, Serialize}; +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +// 1. Define your payload struct +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} + +// 2. Implement the required traits (these macros do it for you) +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); +// ^^^^^^^^^^^^^^^^ +// This string must match exactly on host and guest side + +// 3. Define a hook type that uses your payload +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// 4. Register your payload with the factory +let mut registry = PayloadSerializerRegistry::new(); +registry.register::(); +let factory = WasmPluginFactory::new(wasm_dir, Arc::new(registry)); +``` + +### Guest side (your plugin — see cpex-wasm-plugin README for full tutorial): + +Define the **exact same struct** with the same field names and types, the same `impl_wasm_payload!` discriminator string, and implement `HookHandler`. The payload crosses the boundary automatically as JSON bytes. + +**Working examples:** See `examples/wasm_plugin_demo.rs` (host) and `cpex-wasm-plugin/src/plugins/tool_invoke_checker.rs` (guest). + +--- + +## Configuring Plugins (YAML) + +Each WASM plugin is declared in YAML with its hooks, mode, priority, capabilities, and sandbox policy: + +```yaml +plugins: + - name: my-plugin + kind: wasm://my-plugin.wasm # "wasm://" prefix tells the manager to use WasmPluginFactory + hooks: [cmf.tool_pre_invoke] # which hooks this plugin runs on + mode: sequential # sequential | transform | audit | concurrent | fire_and_forget + priority: 50 # lower number = runs earlier within same mode + on_error: fail # fail | ignore | disable (circuit breaker) + capabilities: # what extension data the plugin can see/modify + - read_labels + - read_subject + - read_roles + config: + sandbox_policy: + allowed_filesystem: [] # empty = no filesystem access + allowed_network: [] # empty = no network access + allowed_env: [] # empty = no environment variables visible + resources: + max_memory_bytes: 10485760 # 10 MB linear memory cap + max_fuel: 1000000000 # ~1 billion instructions per call + max_execution_time_ms: 5000 # 5 second wall-clock timeout per call + max_instances: 10 + max_tables: 10 +``` + +**Defaults** (if you omit `sandbox_policy` entirely): deny-all filesystem, deny-all network, deny-all env vars, 5-second timeout, unlimited fuel/memory. + +### Routing plugins to specific tools + +```yaml +global: + policies: + all: # these plugins fire on EVERY invocation + plugins: [identity-checker] + pii: # these fire only when a route has the "pii" tag + plugins: [pii-guard] + +routes: + - tool: get_compensation # specific tool + meta: + tags: [pii, hr] # activates the "pii" policy group + plugins: + - audit-logger # plus any route-specific plugins + + - tool: "*" # wildcard catch-all + plugins: + - audit-logger +``` + +--- + +## Running the Demos + +| Demo | What it shows | Command | +|------|---------------|---------| +| **Plugin Demo** | 4 WASM plugins, custom payload, 7 scenarios, policy routing | `make run-plugin-demo` | +| **Capabilities Demo** | 3 WASM plugins, capability-gated extension visibility | `make run-capabilities-demo` | +| **Filesystem Sandbox Demo** | All 6 WASI permission levels — ALLOW and DENY per scenario | `make run-sandbox-demo` | +| **Env Sandbox Demo** | Allowed vs denied env variables — ALLOW and DENY per variable | `make run-env-demo` | +| **Both** | Plugin demo + capabilities demo | `make run-demos` | + +All commands should be run from `crates/cpex-wasm-host`. + +The Plugin Demo mirrors the native `cpex-core/examples/plugin_demo.rs` exactly — same plugins, same scenarios, same results — but all running in WASM sandboxes. See the [detailed Plugin Demo section](#plugin-demo-details) at the end for expected output. + +--- + +## Filesystem Sandbox Permissions Demo + +This demo shows all six WASI filesystem permission levels enforced live by the sandbox. A single plugin (`fs-sandbox-demo.wasm`) accepts `operation` and `path` as ToolCall arguments, attempts the operation using `std::fs`, and returns `ALLOW` if the sandbox permits it or `DENY` (violation code `fs_access_denied`) if WASI blocks it. + +### Running it + +```bash +cd crates/cpex-wasm-host +make run-sandbox-demo +``` + +`make run-sandbox-demo` does three things in order: +1. Builds `fs-sandbox-demo.wasm` (and all other test plugins) +2. Creates `examples/data/` subdirectories and seeds them with files +3. Runs `wasm_fs_sandbox_demo` — prints ALLOW/DENY for each scenario + +### Permission levels + +The six permission levels are declared in `config/config_fs_sandbox_demo.yaml`, one per subdirectory of `examples/data/`: + +| Permission | DirPerms | FilePerms | Directory | What is allowed | +|------------|----------|-----------|-----------|-----------------| +| `read-only` | READ | READ | `rules/` | List dir, read file contents | +| `full-access` | READ + MUTATE | READ + WRITE | `cache/` | All operations | +| `drop-box` | MUTATE | WRITE | `audit/` | `create_dir`, `delete` only | +| `fixed-mutable` | READ | READ + WRITE | `counters/` | Read files; no writes or `create_dir` | +| `list-only` | READ | (empty) | `plugins/` | `list_dir` only; cannot open file contents | +| `private-scratch` | MUTATE | READ + WRITE | `scratch/` | `create_dir`, `delete` only | + +### A note on wasmtime enforcement + +wasmtime-wasi's `open_at` always checks `DirPerms::READ` before allowing any file open — including writes. This has two non-obvious consequences: + +- **`drop-box`** (`DirPerms::MUTATE` only) and **`private-scratch`** (`DirPerms::MUTATE` + `FilePerms::READ|WRITE`) cannot do any file I/O. Despite `FilePerms::WRITE` being set on `private-scratch`, `open_at` is blocked at the dir-permission check before file permissions are ever consulted. Only directory mutations (`create_dir`, `delete`) work. +- **`fixed-mutable`** (`DirPerms::READ` + `FilePerms::READ|WRITE`) cannot write files either. `open_at` also requires `DirPerms::MUTATE` for any write flag (`O_WRONLY`, `O_CREAT`, `O_TRUNC`). In practice it behaves like `read-only` at the file level. + +These constraints are documented in `src/policy_loader.rs`. + +### Expected output + +``` +=== Filesystem Sandbox Permissions Demo === + +--- read-only (rules/) DirPerms::READ FilePerms::READ + [ALLOW expected] operation=read path=examples/data/rules/policy.yaml + → ALLOW + [DENY expected] operation=write path=examples/data/rules/policy.yaml + → DENY [fs_access_denied] + +--- full-access (cache/) DirPerms::READ|MUTATE FilePerms::READ|WRITE + [ALLOW expected] operation=write path=examples/data/cache/output.txt + → ALLOW + [ALLOW expected] operation=read path=examples/data/cache/output.txt + → ALLOW + +--- drop-box (audit/) DirPerms::MUTATE FilePerms::WRITE + [ALLOW expected] operation=create_dir path=examples/data/audit/events + → ALLOW + [DENY expected] operation=read path=examples/data/audit/events + → DENY [fs_access_denied] + +--- fixed-mutable (counters/) DirPerms::READ FilePerms::READ|WRITE + [ALLOW expected] operation=read path=examples/data/counters/rate.txt + → ALLOW + [DENY expected] operation=create_dir path=examples/data/counters/new + → DENY [fs_access_denied] + +--- list-only (plugins/) DirPerms::READ FilePerms::empty() + [ALLOW expected] operation=list_dir path=examples/data/plugins + → ALLOW + [DENY expected] operation=read path=examples/data/plugins/fs-sandbox-demo.wasm + → DENY [fs_access_denied] + +--- private-scratch (scratch/) DirPerms::MUTATE FilePerms::READ|WRITE + [ALLOW expected] operation=create_dir path=examples/data/scratch/work + → ALLOW + [DENY expected] operation=list_dir path=examples/data/scratch + → DENY [fs_access_denied] + +=== Demo complete === +``` + +### How the plugin works + +`fs_sandbox_demo.rs` extracts `operation` and `path` from the first `ToolCall` in the message payload and dispatches to one of five `std::fs` functions: + +| `operation` | Underlying call | +|-------------|----------------| +| `read` | `std::fs::read_to_string(path)` | +| `write` | `std::fs::write(path, b"...")` | +| `create_dir` | `std::fs::create_dir_all(path)` | +| `list_dir` | `std::fs::read_dir(path)` | +| `delete` | `std::fs::remove_file` / `remove_dir_all` | + +On success → `PluginResult::allow()` with operation details stored in `ctx`. +On any `std::io::Error` (which is how WASI enforcement surfaces) → `PluginResult::deny(PluginViolation::new("fs_access_denied", ...))`. + +The paths passed must match the guest mount points registered by `preopened_dir`. Because `policy_loader.rs` calls `preopened_dir(dir, dir, ...)` (host path = guest path), the plugin uses the same relative path that appears in the YAML config — e.g. `examples/data/rules/policy.yaml`. Absolute host paths are invisible inside the sandbox. The demo must be run from `crates/cpex-wasm-host/` so these relative paths resolve correctly. + +### Relevant files + +| File | Role | +|------|------| +| `config/config_fs_sandbox_demo.yaml` | YAML config — declares the plugin and all 6 filesystem rules | +| `examples/wasm_fs_sandbox_demo.rs` | Host-side demo — builds payloads, invokes plugin, prints results | +| `crates/cpex-wasm-plugin/src/plugins/fs_sandbox_demo.rs` | Guest plugin — attempts `std::fs` operations, returns ALLOW/DENY | +| `src/policy_loader.rs` | Maps permission strings to `(DirPerms, FilePerms)` bitflag pairs | + +--- + +## Environment Variable Sandbox Demo + +This demo shows how `allowed_env` controls which host environment variables are visible inside the WASM sandbox. A single plugin (`env-sandbox-demo.wasm`) accepts `env_var` as a ToolCall argument, calls `std::env::var` inside the sandbox, and returns `ALLOW` if the variable is visible or `DENY` (violation code `env_access_denied`) if it is not. + +### Running it + +```bash +cd crates/cpex-wasm-host +make run-env-demo +``` + +### How env enforcement works + +`build_wasi_context` in `src/policy_loader.rs` iterates `allowed_env` and calls `builder.env(key, val)` for each variable that is both listed and present on the host: + +```rust +for key in &policy.allowed_env { + if let Ok(val) = std::env::var(key) { + builder.env(key, &val); + } +} +``` + +`inherit_env()` is never called, so no host variable leaks implicitly. Inside the sandbox, `std::env::var` only sees what was explicitly injected. A variable that is set on the host but absent from `allowed_env` returns `Err(NotPresent)` inside the plugin — indistinguishable from a variable that was never set. + +### Scenarios + +The demo uses five variables to illustrate the two cases: + +| Variable | In `allowed_env`? | Host value | Sandbox sees | Expected | +|----------|:-----------------:|------------|-------------|---------| +| `CPEX_APP_TOKEN` | Yes | `tok-demo-abc123` | visible | ALLOW | +| `CPEX_LOG_LEVEL` | Yes | `info` | visible | ALLOW | +| `HOME` | No | set by shell | hidden | DENY | +| `PATH` | No | set by shell | hidden | DENY | +| `SECRET_API_KEY` | No | `sk-super-secret-*` | hidden | DENY | + +### Expected output + +``` +=== Environment Variable Sandbox Permissions Demo === + +Plugin: env-sandbox-demo.wasm +Payload: env_var passed as ToolCall argument + +Host env vars set for this demo: + CPEX_APP_TOKEN = tok-demo-abc123 (in allowed_env → visible) + CPEX_LOG_LEVEL = info (in allowed_env → visible) + HOME = /Users/... (not in allowed_env → hidden) + PATH = (not in allowed_env → hidden) + SECRET_API_KEY = sk-super-secret-* (not in allowed_env → hidden) + +--- allowed_env variables (visible inside sandbox) + [ALLOW expected] env_var=CPEX_APP_TOKEN + → ALLOW + [ALLOW expected] env_var=CPEX_LOG_LEVEL + → ALLOW + +--- variables NOT in allowed_env (hidden inside sandbox) + [DENY expected] env_var=HOME + → DENY [env_access_denied] + [DENY expected] env_var=PATH + → DENY [env_access_denied] + [DENY expected] env_var=SECRET_API_KEY + → DENY [env_access_denied] + +=== Demo complete === +``` + +### Relevant files + +| File | Role | +|------|------| +| `config/config_env_sandbox_demo.yaml` | YAML config — declares the plugin and `allowed_env` list | +| `examples/wasm_env_sandbox_demo.rs` | Host-side demo — sets env vars, invokes plugin, prints results | +| `crates/cpex-wasm-plugin/src/plugins/env_sandbox_demo.rs` | Guest plugin — calls `std::env::var`, returns ALLOW/DENY | +| `src/policy_loader.rs` | `build_wasi_context` — injects allowed vars via `builder.env` | + +--- + +## Running Tests + +```bash +# From the repository root: +cargo test -p cpex-wasm-host +``` + +This runs all tests that have their `.wasm` binaries pre-built. For the full suite including sandbox isolation tests: + +```bash +# One command that cleans, rebuilds everything, and runs all tests: +cd crates/cpex-wasm-host +make test +``` + +### What the tests cover + +| Category | Count | What it proves | +|----------|:-----:|----------------| +| Security enforcement | 15 | Capability filtering, immutable tier, monotonic labels, write authorization, slot preservation | +| Custom payload pipeline | 8 | 4 WASM plugins with user-defined payload through the full PluginManager pipeline (E2E) | +| Sandbox isolation | 6 | Real plugins attempt filesystem/network/env access — sandbox blocks them | +| Network policy enforcement | 5 | Port, scheme, method, host, and wildcard constraints enforced by WasiHttpHooks (WASM-level) | +| Resource limits | 3 | Fuel exhaustion, epoch timeout, and memory cap actually trap a running plugin | +| Policy loader | 8 | YAML config parsing, context building, deny-all defaults | +| Config integration | 8 | Config structure, resource limits validation | +| Error classification | 6 | Timeout, fuel, memory, trap, network errors classified correctly | +| Conversions | 3 | Payload round-trips, extension immutability | + +--- + +## Performance + +**Benchmark environment:** Apple M4 Max, 64 GB RAM, macOS 15.5, Rust 1.96.0, wasmtime 45.0, release profile. + +![Performance Comparison](benchmarking/performance_comparison.png) + +| Scenario | Latency | vs Native | +|----------|:-------:|:---------:| +| Native handler (noop) | 87 ns | 1x | +| Native compute (real work) | 874 ns | 10x | +| WASM noop (sandbox overhead only) | 5.1 µs | 58x | +| WASM compute (same real work) | 10.5 µs | 120x | +| Custom payload (JSON serde path) | 5.3 µs | 61x | +| Structured payload (WIT types) | 5.6 µs | 64x | +| Cold start (first load + compile) | 547 ms | one-time | + +### The bottom line + +| | Native | WASM | +|---|---|---| +| **Per-call latency** | 87ns - 874ns | 5-10µs (12-120x slower) | +| **Cold start** | Zero (compiled in) | ~550ms (one-time WASM compilation) | +| **Concurrency** | Lock-free | Mutex-serialized per plugin | +| **Isolation** | None (same process, trusted) | Full sandbox (fuel, memory, timeout, network, filesystem) | + +**For typical CPEX usage** (2-4 plugin calls per LLM request at 200ms+), the WASM overhead is 20-40µs total — **0.01-0.02% of request latency**. Effectively invisible. + +**When to use WASM:** untrusted/third-party plugins, multi-tenant environments, or anywhere you need the guarantee that a plugin can't read files, exfiltrate data, or crash the host. + +**When to use native:** your own code, performance-critical hot paths, or plugins that need async I/O or cross-invocation mutable state beyond what `OnceLock` provides. + +### Running benchmarks yourself + +```bash +cd crates/cpex-wasm-host +make bench-all +``` + +This builds all required plugins, runs both benchmark suites (Criterion), and generates the performance comparison chart. See `benchmarking/README.md` for a detailed step-by-step guide. + +--- + +## Security Model + +Five layers of defense-in-depth at the WASM trust boundary: + +| Layer | What It Does | On Violation | +|-------|-------------|--------------| +| **Capability filtering** | Strips extension slots the plugin isn't authorized to read | Plugin never receives unauthorized data | +| **Immutable tier** | Verifies Arc pointer identity on immutable slots after return | Rejects all extension modifications | +| **Monotonic labels** | Verifies security labels can only be added, never removed | Rejects extension modifications | +| **Write authorization** | Verifies mutations only on slots with write capability | Rejects extension modifications | +| **Slot preservation** | Hidden slots preserved unchanged during writeback | Pipeline data integrity maintained | + +Additionally, the sandbox enforces: +- **Fuel limits** — per-invocation instruction budget (prevents infinite loops) +- **Execution timeout** — epoch-based wall-clock limit (prevents hanging) +- **Memory caps** — linear memory growth limited (prevents OOM) +- **Network allowlist** — outbound HTTP only to declared hosts +- **Filesystem preopens** — only declared paths accessible + +**Stdio (always-on, not configurable):** `inherit_stdio()` is called unconditionally for every plugin (`policy_loader.rs:204`). Plugin `println!`/`eprintln!` output passes through to the host process's stdout/stderr. Stdin is inherited but no plugin reads from it. This is not currently controllable via `SandboxPolicy` — there is no YAML knob to suppress or redirect plugin stdio per-plugin. + +--- + +## Error Handling + +WASM runtime errors are classified into proper `PluginError` variants so the executor can apply the correct `on_error` policy: + +| Error | Variant | What happened | +|-------|---------|---------------| +| Epoch deadline exceeded | `Timeout` | Plugin took too long (wall-clock) | +| Fuel exhausted | `Execution { code: "fuel_exhausted" }` | Plugin used too many instructions | +| Memory limit | `Execution { code: "memory_limit" }` | Plugin tried to allocate too much memory | +| Plugin trap/panic | `Execution { code: "wasm_trap" }` | Plugin crashed (unreachable, divide by zero, etc.) | +| Network denied | `Execution { code: "network_denied" }` | Plugin tried to access a non-allowed host | + +With `on_error: disable`, the executor permanently disables a failing plugin (circuit breaker pattern). + +--- + +## API Reference + +### `WasmPluginFactory` + +The main entry point. Implements `cpex_core::factory::PluginFactory` so WASM plugins register with `PluginManager` the same way native plugins do. + +```rust +use cpex_wasm_host::factory::WasmPluginFactory; + +// For built-in payloads (CMF, Identity, Delegation): +let factory = WasmPluginFactory::with_builtin_payloads(PathBuf::from("wasm")); + +// For custom payloads (you must register your types): +let factory = WasmPluginFactory::new(wasm_dir, Arc::new(registry)); +``` + +### `PayloadSerializerRegistry` + +Tells the host how to serialize/deserialize your custom payload types for the WASM boundary. + +```rust +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +let mut registry = PayloadSerializerRegistry::new(); +registry.register::(); // MyPayload must impl WasmSerializablePayload +``` + +### `SharedEngine` + +One wasmtime engine + one epoch ticker thread, shared across all plugins from the same factory. You don't create this directly — `WasmPluginFactory` manages it internally. + +### `SandboxManager` + +Per-plugin isolated Store. Also managed internally by the factory. If you need direct access (e.g., for benchmarks), see `benchmarking/invocation.rs` for usage patterns. + +--- + +## Project Structure + +``` +cpex-wasm-host/ +├── Cargo.toml +├── README.md +├── Makefile # Build, test, run, bench targets (see table below) +├── config/ +│ ├── config.yaml # Test fixture (policy loader tests) +│ ├── config_plugin_demo.yaml # 4-plugin custom payload demo +│ ├── config_capabilities.yaml # 3-plugin capabilities demo +│ ├── config_fs_sandbox_demo.yaml # 6 WASI filesystem permission levels demo +│ └── config_env_sandbox_demo.yaml # env variable sandbox demo +├── examples/ +│ ├── wasm_plugin_demo.rs # 4 plugins, custom payload, 7 scenarios +│ ├── wasm_capabilities_demo.rs # 3 plugins, capability isolation +│ ├── wasm_fs_sandbox_demo.rs # all 6 filesystem permission levels +│ └── wasm_env_sandbox_demo.rs # env variable allow/deny scenarios +├── benchmarking/ +│ ├── README.md # Step-by-step benchmarking guide +│ ├── invocation.rs # Benchmark: sandbox overhead +│ ├── comprehensive.rs # Benchmark: real work, cold start, contention +│ ├── plot_results.py # Generates performance_comparison.png +│ └── performance_comparison.png # Chart (committed for README display) +├── tests/ +│ ├── test_security_enforcement.rs # 15 tests: 5-layer security validation +│ ├── test_custom_payload_pipeline.rs # 8 tests: E2E custom payload pipeline +│ ├── test_sandbox_isolation.rs # 2 tests: filesystem denied +│ ├── test_sandbox_network.rs # 7 tests: network denied + port/scheme/method enforcement +│ ├── test_sandbox_env.rs # 2 tests: env vars hidden +│ ├── test_sandbox_resource_limits.rs # 3 tests: fuel/timeout/memory trap a real plugin +│ └── test_policy_loader.rs # 8 tests: config parsing +├── src/ +│ ├── lib.rs # Crate docs + module re-exports +│ ├── factory.rs # WasmPluginFactory, WasmBridgeHandler +│ ├── sandbox_manager.rs # SharedEngine, SandboxManager, host-logging +│ ├── conversions.rs # Native ↔ WIT type conversions +│ ├── policy_loader.rs # SandboxPolicy parsing, WASI context +│ └── payload_registry.rs # Custom payload serialization +├── wasm/ # Compiled .wasm binaries (gitignored) +└── wit/ + ├── world.wit # WIT interface definition + └── deps/ # WASI P2 interface dependencies +``` + +### Makefile targets + +All targets are run from `crates/cpex-wasm-host`. + +| Target | What it does | +|--------|-------------| +| `all` | Build all demo plugins and compile host examples (default) | +| `build-examples` | Compile host example binaries only | +| `build-all-plugins` | Build all demo WASM plugins (`DEMO_PLUGINS` list) and stage to `wasm/` | +| `build-test-plugins` | Build all test/sandbox WASM plugins (`TEST_PLUGINS` list) and stage to `wasm/` | +| `build-bench-plugins` | Build benchmark WASM plugins (`BENCH_PLUGINS` list) and stage to `wasm/` | +| `run-demos` | Build all plugins + run plugin demo and capabilities demo | +| `run-plugin-demo` | Build all plugins + run custom-payload plugin demo | +| `run-capabilities-demo` | Build all plugins + run capability isolation demo | +| `run-sandbox-demo` | Build test plugins + seed data + run filesystem permissions demo | +| `run-env-demo` | Build test plugins + run env variable sandbox demo | +| `bench-all` | Build everything + run benchmarks + generate performance chart | +| `test` | Clean, rebuild everything, run all host + plugin unit tests | +| `clean` | Remove host build artifacts and `wasm/*.wasm` | +| `clean-all` | Remove host + plugin build artifacts and all `.wasm` files | +| `help` | List all targets with descriptions | + +**Plugin lists** (single source of truth in the Makefile variables): +- `DEMO_PLUGINS` — compiled by `build-all-plugins`; the capabilities and plugin demos use these +- `TEST_PLUGINS` — compiled by `build-test-plugins`; the sandbox isolation and integration tests use these +- `BENCH_PLUGINS` — compiled by `build-bench-plugins`; the benchmark suite uses these + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `target 'wasm32-wasip2' not found` | WASM target not installed | `rustup target add wasm32-wasip2` | +| `failed to load wasm from .../plugin.wasm` | Binary not built | `cd crates/cpex-wasm-host && make build-all-plugins` | +| `failed to instantiate plugin` | Stale `.wasm` (WIT mismatch after code changes) | Rebuild: `make clean-all && make build-all-plugins` | +| `WASM invocation failed: epoch deadline` | Plugin took too long | Increase `max_execution_time_ms` in YAML config | +| `WASM invocation failed: all fuel consumed` | Plugin too compute-heavy | Increase `max_fuel` in YAML config | +| `block_in_place` panic | Single-threaded tokio runtime | Use `tokio::runtime::Builder::new_multi_thread()` or `#[tokio::main]` (multi-thread is default) | +| Benchmark says "SKIP: noop.wasm not found" | Benchmark plugins not built | `make build-bench-plugins` | +| Sandbox tests fail with "not found" | Test plugins not built | `make build-test-plugins` | + +--- + +## Known Limitations & Future Work + +### Current Limitations + +| Limitation | Impact | Workaround | +|-----------|--------|------------| +| **Single-threaded per plugin** | Mutex serializes all calls to one plugin — no parallel invocations | Acceptable at ~126K calls/sec/core; bottleneck only under extreme concurrency | +| **One plugin per `.wasm` binary** | WIT single-export constraint | Use feature flags; minor build-time cost | +| **Cold start ~550ms** | First load compiles WASM to native code | Load all plugins at startup, not per-request | +| **No raw credential access** | Bearer tokens never cross the boundary | By design — plugins needing credentials should run natively | +| **No streaming** | Full payload buffering, no per-token hooks | Use native plugins for streaming paths | +| **No WIT schema versioning** | WIT changes are breaking — all plugins must be recompiled | Pin WIT version across releases | +| **Rust-only guest SDK** | No Go/TypeScript/Python convenience wrappers | Any `wasm32-wasip2` language works; Rust SDK is just the ergonomic layer | +| **Stdio always-on** | Plugin `println!`/`eprintln!` always passes through to host stdout/stderr; no per-plugin suppression or capture | Use the `host_logging` WIT interface for structured logs; raw stdio is for debugging only | +| **Memory not reclaimed between calls** | Linear memory allocated during one invocation is retained for the lifetime of the store — a plugin near `max_memory_bytes` on call 1 has less headroom on call 2 | Addressed by instance pooling (Future Work), which gives each call a fresh instance | +| **`max_fuel` is per-invocation, not lifetime** | Fuel resets to the full budget at the start of every `invoke()` call — total compute across N calls is unconstrained | By design for this use case; configure conservatively if cumulative compute is a concern | +| **`max_instances` / `max_tables` not actively constraining** | Each plugin uses exactly one component instance and one table — these limits are not reached in normal operation | Safe to configure; become relevant only if plugins instantiate sub-components dynamically | +| **Wall-clock timeout, not CPU time** | `max_execution_time_ms` is enforced via epoch interruption (wall-clock ticks every 1ms) — CPU time and wall-clock time are not distinguished | A plugin that blocks on I/O counts the same as one spinning on CPU | + +### Future Work + +| Priority | Feature | Benefit | +|:--------:|---------|---------| +| High | **Instance pooling** | N pre-warmed Stores per plugin, round-robin dispatch. Eliminates mutex contention. | +| High | **Pre-compilation cache** | Serialize compiled modules to disk. Reduces cold start from ~550ms to <5ms. | +| Medium | **Per-token streaming hooks** | `HookPayload::Stream` variant for LLM token-level processing. | +| Medium | **Hot reload** | Replace `.wasm` binary without restarting the host process. | +| Medium | **Multi-language guest SDKs** | TypeScript/Go/Python wrappers for the guest-side plugin API. | +| Low | **WIT version negotiation** | Host and guest report versions at load time. Enables rolling upgrades. | +| Low | **Resource usage metrics** | Expose fuel consumed, memory high-water mark per plugin. | +| Low | **Plugin-to-plugin communication** | Shared memory or message-passing between plugins in the same pipeline. | +| Low | **Raw socket capabilities** | `wasi:sockets` TCP/UDP/DNS access for plugins that need it. See attack surface note below. | +| Low | **Configurable stdio** | Per-plugin `allow_stdout`, `allow_stderr`, `capture_stdout` YAML fields. Useful for suppressing plugin raw output in production or collecting it as a debug field in the result. | + +--- + +### Raw Socket Capabilities (Future Extension) + +wasmtime's `WasiCtxBuilder` exposes four socket capability knobs that are **not wired today**: + +| Capability | `WasiCtxBuilder` method | What it enables | +|-----------|------------------------|----------------| +| TCP client | `allow_tcp(true)` | Plugin can open outbound TCP connections | +| UDP | `allow_udp(true)` | Plugin can send/receive UDP datagrams | +| DNS resolution | `allow_ip_name_lookup(true)` | Plugin can resolve hostnames | +| Per-connection ACL | `socket_addr_check(fn)` | Callback checked for every connect/bind attempt | + +Currently `wasi:sockets` is not imported in `world.wit` and not linked in `SharedEngine::new()`, so plugins have **no raw socket access whatsoever** — structurally blocked, not just policy-blocked. The `WasiHttpHooks` enforcement layer (port/scheme/method/host filtering) covers the only network surface plugins actually have: WASI HTTP. + +**Why not implement it now:** CPEX plugins are LLM tool-call processors. They receive a request, check it, and return allow/deny. There is no use case in this pattern that requires raw TCP/UDP/DNS. Adding socket access now would widen the attack surface without providing value. + +**Attack surface implications if implemented in the future:** + +1. **HTTP allow-list bypass.** Raw TCP lets a plugin open a connection to any IP address. Even with `socket_addr_check` filtering by IP, the plugin could connect to an IP that resolves from an allowed domain name, bypassing scheme and method checks entirely. The current `WasiHttpHooks` layer enforces port/scheme/method/host — none of those apply to raw TCP frames. + +2. **DNS-based exfiltration.** With `allow_ip_name_lookup`, a plugin can encode data in DNS query hostnames (`.attacker.com`) and leak it through the resolver. DNS exfiltration bypasses HTTP-level controls entirely and is difficult to detect without deep packet inspection at the host OS level. + +3. **UDP amplification / reflection.** UDP is connectionless. A plugin with `allow_udp` could spoof source addresses (within the container's network namespace) and send amplified traffic to third parties, making the host a participant in a reflected DDoS attack. + +4. **Port-scan / lateral movement.** Raw TCP with a permissive `socket_addr_check` allows scanning internal network ranges (RFC 1918 addresses) that are unreachable via the HTTP allow-list. This enables a compromised plugin to map internal services and reach them directly. + +5. **TLS stripping.** Raw TCP bypasses scheme enforcement. A plugin could initiate an HTTP (not HTTPS) connection to a host whose `NetworkRule` requires HTTPS, stripping transport-layer encryption for the data it sends. + +**If socket access is ever needed:** + +- Add `wasi:sockets` to `world.wit` and link it in `SharedEngine::new()` — this is a deliberate, visible opt-in. +- Wire all four capabilities behind `SandboxPolicy` fields (e.g., `allow_tcp`, `allow_udp`, `allow_dns`), defaulting to `false`. +- Implement `socket_addr_check` as an IP-based ACL that mirrors the hostname allow-list: resolve the allowed `NetworkRule` hosts at policy-load time and only permit connections to those IPs. +- Treat any plugin requesting socket access as a higher trust tier — flag it prominently in config validation and require explicit operator acknowledgment. + +--- + +## Appendix: Plugin Demo Details + + + +The `wasm_plugin_demo` example runs 4 WASM plugins through 7 scenarios, demonstrating the full feature set: + +| Plugin | Binary | Priority | Mode | Role | +|--------|--------|:--------:|------|------| +| identity-resolver | `tool-invoke-checker.wasm` | 10 | sequential | Checks user identity is present | +| pii-guard | `pii-guard.wasm` | 20 | sequential | Blocks PII tools without clearance | +| remote-authz | `remote-authz.wasm` | 30 | sequential | ACL-based authorization (state persists across calls) | +| audit-logger | `audit-logger-custom.wasm` | 100 | fire_and_forget | Logs all tool invocations | + +**Expected output:** + +``` +Scenario 1: get_compensation (no clearance) → DENIED by pii-guard +Scenario 2: get_compensation (with clearance) → ALLOWED (+ post-invoke) +Scenario 3: list_departments (non-PII) → ALLOWED +Scenario 4: some_other_tool (wildcard) → ALLOWED +Scenario 5: query_external_data (alice in ACL) → ALLOWED +Scenario 6: query_external_data (charlie) → DENIED by remote-authz +Scenario 7: list_departments (no user) → DENIED by identity-resolver +``` + +Run with: `cd crates/cpex-wasm-host && make run-plugin-demo` diff --git a/crates/cpex-wasm-host/benchmarking/README.md b/crates/cpex-wasm-host/benchmarking/README.md new file mode 100644 index 00000000..5a1bff77 --- /dev/null +++ b/crates/cpex-wasm-host/benchmarking/README.md @@ -0,0 +1,236 @@ +# Benchmarking Guide + +Step-by-step guide to running the CPEX WASM plugin performance benchmarks. This measures how much overhead the WASM sandbox adds compared to native plugin execution. + +--- + +## What You'll Learn + +After running these benchmarks, you'll know: + +1. **How much slower is WASM than native?** — for identical workloads (noop, real computation, full extensions) +2. **How long does the first plugin load take?** — cold start includes WASM compilation +3. **Does the payload format matter?** — custom JSON payload vs structured WIT types +4. **How does concurrency scale?** — what happens when multiple requests hit the same plugin simultaneously + +--- + +## Prerequisites + +Before you start, make sure you have these installed: + +```bash +# 1. Rust with the WASM target +rustup target add wasm32-wasip2 + +# 2. Python 3 + matplotlib (for chart generation — optional) +pip3 install matplotlib + +# 3. Verify you're in the repository root +cd /path/to/contextforge-plugins-framework +``` + +--- + +## Step 1: Build the WASM Plugin Binaries + +The benchmarks load pre-compiled `.wasm` binaries from the `wasm/` directory. You need to build them first. + +**Easiest way (builds everything):** + +```bash +cd crates/cpex-wasm-host +make build-all-plugins build-bench-plugins build-test-plugins +``` + +This builds: +- `noop.wasm` — does nothing, returns immediately (measures pure sandbox overhead) +- `compute-bench.wasm` — does real work: JSON parsing, string manipulation, hash computation +- `tool-invoke-checker.wasm` — handles a custom payload type (measures the JSON serde path) +- All other plugin binaries needed for the full benchmark suite + +**Manual way (if you only want specific benchmarks):** + +```bash +cd crates/cpex-wasm-plugin + +# For invocation.rs benchmarks (overhead measurement) +cargo build --target wasm32-wasip2 --release --features noop --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/noop.wasm + +# For comprehensive.rs benchmarks (real-work comparison) +cargo build --target wasm32-wasip2 --release --features compute-bench --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/compute-bench.wasm + +# For custom payload benchmark +cargo build --target wasm32-wasip2 --release --features tool-invoke-checker --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/tool-invoke-checker.wasm +``` + +--- + +## Step 2: Run the Benchmarks + +From the repository root (or `crates/cpex-wasm-host`): + +```bash +# Run ALL benchmarks (both suites) +cargo bench -p cpex-wasm-host +``` + +This runs two benchmark suites: + +### Suite 1: `invocation` — Measures sandbox overhead + +| Benchmark | What it measures | +|-----------|-----------------| +| `native_noop` | Baseline: calling a native handler directly (no sandbox) | +| `native_with_full_extensions` | Native handler with realistic extensions (security + HTTP) | +| `conversion_native_to_wit` | Just the type conversion cost (native types → WIT types) | +| `wasm_noop` | Full WASM round-trip with minimal payload (pure overhead) | +| `wasm_with_full_extensions` | Full WASM round-trip with realistic extensions | + +### Suite 2: `comprehensive` — Measures real-world scenarios + +| Benchmark | What it measures | +|-----------|-----------------| +| `cold_start_wasm` | Time to load a `.wasm` file + compile it + run the first call | +| `compute_native` | Native handler doing real work (JSON parse + string ops + hash) | +| `compute_wasm` | Same real work through the WASM sandbox | +| `custom_payload_wasm` | User-defined payload via JSON serde (`HookPayload::Custom`) | +| `structured_payload_wasm` | Built-in CMF payload via WIT types (`HookPayload::Cmf`) | +| `concurrent_contention/1` | 1 task calling the sandbox (baseline) | +| `concurrent_contention/4` | 4 tasks contending on the same sandbox mutex | +| `concurrent_contention/8` | 8 tasks contending on the same sandbox mutex | + +**To run just one suite:** + +```bash +cargo bench -p cpex-wasm-host --bench invocation +cargo bench -p cpex-wasm-host --bench comprehensive +``` + +**To run a specific benchmark:** + +```bash +cargo bench -p cpex-wasm-host -- compute_native +cargo bench -p cpex-wasm-host -- cold_start +``` + +--- + +## Step 3: Read the Results + +Criterion prints results to the terminal like this: + +``` +compute_native time: [870 ns 874 ns 878 ns] +compute_wasm time: [10.4 µs 10.5 µs 10.6 µs] +``` + +The three numbers are: [lower bound, median, upper bound] of the 95% confidence interval. + +**How to interpret:** +- `compute_native: 874 ns` = calling the handler natively takes ~874 nanoseconds +- `compute_wasm: 10.5 µs` = same work through WASM takes ~10,500 nanoseconds +- Ratio: 10,500 / 874 = **~12x overhead** for the sandbox + +### HTML Reports + +Criterion also generates detailed HTML reports with histograms and regression analysis: + +```bash +open target/criterion/report/index.html +``` + +Each benchmark has its own page with: +- Distribution plot (how spread out the measurements are) +- Regression comparison (if you've run before, shows improvement/regression) +- Outlier analysis + +--- + +## Step 4: Generate the Comparison Chart (Optional) + +A Python script reads Criterion's JSON output and produces a visual bar chart: + +```bash +cd crates/cpex-wasm-host/benchmarking +python3 plot_results.py +``` + +This produces `performance_comparison.png` in the same directory — a 3-panel chart showing: +1. Native vs WASM for identical workloads (log scale) +2. Payload path cost + cold start +3. Mutex contention scaling + +**Requirements:** Python 3 + matplotlib (`pip3 install matplotlib`) + +--- + +## Step 5: One-Command Full Run (Recommended) + +If you just want to do everything at once: + +```bash +cd crates/cpex-wasm-host +make bench-all +``` + +This: +1. Builds all WASM plugin binaries (demo + bench + test plugins) +2. Runs both benchmark suites +3. Generates the performance comparison chart + +--- + +## Understanding the Results + +### What the numbers mean for your use case + +| Your scenario | Plugin calls per request | WASM overhead | Request latency | Impact | +|---------------|:-----------------------:|:-------------:|:---------------:|:------:| +| LLM tool invoke | 2-4 calls | 20-40 µs | 200ms - 2s | **< 0.02%** | +| HTTP API gateway | 3-6 calls | 30-60 µs | 5-50ms | **0.06-1.2%** | +| Real-time streaming | 100+ calls | 500µs+ | per-token | **Consider native** | + +### Key takeaways + +- **WASM is 12-120x slower than native** depending on workload and extensions size +- **Cold start is ~550ms** — load plugins at startup, not per-request +- **Custom payload vs structured**: nearly identical cost (~5µs each) — JSON serde is not a bottleneck +- **Mutex contention**: per-call latency stays flat as tasks increase (the mutex serializes, but each call is fast) +- **For typical usage** (2-4 plugin calls per 200ms+ LLM request): overhead is invisible + +### When to choose WASM vs native + +| Choose WASM when... | Choose native when... | +|--------------------|-----------------------| +| Running untrusted/third-party plugins | Running your own trusted code | +| Multi-tenant isolation is required | Per-call latency is critical (<1µs) | +| You need resource limits (fuel, memory, timeout) | You need async I/O in the plugin | +| Plugins should not access filesystem/network | Plugins need streaming/per-token hooks | + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `SKIP: noop.wasm not found` | Run `make build-all-plugins build-bench-plugins build-test-plugins` | +| `SKIP: compute-bench.wasm not found` | Run `make build-bench-plugins` | +| Benchmark numbers vary wildly | Close other apps, disable Turbo Boost, run multiple times | +| `cargo bench` shows "0 benchmarks" | Make sure you're running from the workspace root or using `-p cpex-wasm-host` | +| `python3 plot_results.py` fails | Install matplotlib: `pip3 install matplotlib` | +| Chart shows "No benchmark results found" | Run `cargo bench -p cpex-wasm-host` first to generate Criterion data | + +--- + +## File Reference + +| File | Purpose | +|------|---------| +| `invocation.rs` | Benchmark suite 1: measures framework overhead (sandbox crossing cost) | +| `comprehensive.rs` | Benchmark suite 2: measures real-world scenarios (compute, cold start, contention) | +| `plot_results.py` | Reads Criterion JSON output, generates `performance_comparison.png` | +| `performance_comparison.png` | Generated chart — commit this to show results in README | diff --git a/crates/cpex-wasm-host/benchmarking/comprehensive.rs b/crates/cpex-wasm-host/benchmarking/comprehensive.rs new file mode 100644 index 00000000..91d43b93 --- /dev/null +++ b/crates/cpex-wasm-host/benchmarking/comprehensive.rs @@ -0,0 +1,482 @@ +// Location: ./crates/cpex-wasm-host/benchmarking/comprehensive.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Comprehensive benchmarks: cold start, real computation, custom payload, concurrency. +//! +//! Complements invocation.rs (which measures isolation overhead) with benchmarks +//! that answer: "how does WASM compare for real work?" and "what's the custom +//! payload path cost?". +//! +//! Run: cargo bench -p cpex-wasm-host --bench comprehensive + +use std::path::PathBuf; +use std::sync::Arc; + +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; +use tokio::runtime::Runtime; +use tokio::sync::Mutex; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::message::MessagePayload; +use cpex_core::cmf::{CmfHook, ContentPart, Message, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::http::HttpExtension; +use cpex_core::extensions::security::SecurityExtension; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +// --------------------------------------------------------------------------- +// Native compute plugin (identical logic to the WASM compute-bench plugin) +// --------------------------------------------------------------------------- + +struct NativeComputePlugin; + +static COMPUTE_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for NativeComputePlugin { + fn config(&self) -> &PluginConfig { + COMPUTE_CONFIG.get_or_init(|| PluginConfig { + name: "native-compute-bench".into(), + kind: "builtin".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + ..Default::default() + }) + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for NativeComputePlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + let args_json = payload + .message + .get_tool_calls() + .first() + .map(|tc| serde_json::to_string(&tc.arguments).unwrap_or_default()) + .unwrap_or_default(); + + let mut summary = String::with_capacity(256); + summary.push_str("tool="); + summary.push_str( + payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("?"), + ); + if let Some(ref sec) = extensions.security { + for label in sec.labels.iter() { + summary.push_str(",label="); + summary.push_str(label); + } + } + if let Some(ref http) = extensions.http { + if let Some(req_id) = http.get_header("X-Request-ID") { + summary.push_str(",req_id="); + summary.push_str(req_id); + } + } + + let hash: u64 = args_json.bytes().fold(14695981039346656037u64, |acc, b| { + acc.wrapping_mul(1099511628211).wrapping_add(b as u64) + }); + + ctx.set_local("hash", serde_json::json!(hash)); + ctx.set_local("summary_len", serde_json::json!(summary.len())); + + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Custom payload types (for payload path comparison) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +#[allow(dead_code)] +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_compensation".into(), + arguments: [ + ("employee_id".to_string(), serde_json::json!(42)), + ("department".to_string(), serde_json::json!("engineering")), + ("include_bonus".to_string(), serde_json::json!(true)), + ] + .into(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +fn make_full_extensions() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR_DATA"); + security.add_label("CONFIDENTIAL"); + + let mut http = HttpExtension::default(); + http.set_header( + "Authorization", + "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + ); + http.set_header("X-Request-ID", "req-bench-001"); + http.set_header("Content-Type", "application/json"); + http.set_header("X-Correlation-ID", "corr-12345"); + + Extensions { + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + ..Default::default() + } +} + +fn wasm_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm") +} + +fn compute_wasm_path() -> PathBuf { + wasm_dir().join("compute-bench.wasm") +} + +fn noop_wasm_path() -> PathBuf { + wasm_dir().join("noop.wasm") +} + +fn tool_invoke_checker_path() -> PathBuf { + wasm_dir().join("tool-invoke-checker.wasm") +} + +// --------------------------------------------------------------------------- +// Benchmark: Cold Start (load + first invocation) +// --------------------------------------------------------------------------- + +fn bench_cold_start(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = noop_wasm_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_cold_start: noop.wasm not found"); + return; + } + + let shared = Arc::new(SharedEngine::new().unwrap()); + + c.bench_function("cold_start_wasm", |b| { + b.to_async(&rt).iter(|| { + let shared = shared.clone(); + let path = wasm_path.clone(); + async move { + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, None, "cold-start-bench") + .await + .unwrap(); + + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&make_payload()), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +// --------------------------------------------------------------------------- +// Benchmark: Real Computation (native vs WASM) +// --------------------------------------------------------------------------- + +fn bench_compute_native(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let payload = make_payload(); + let ext = make_full_extensions(); + + c.bench_function("compute_native", |b| { + b.to_async(&rt).iter(|| async { + let mut ctx = PluginContext::default(); + let result = NativeComputePlugin + .handle(black_box(&payload), black_box(&ext), &mut ctx) + .await; + black_box(result); + }); + }); +} + +fn bench_compute_wasm(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = compute_wasm_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_compute_wasm: compute-bench.wasm not found"); + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "compute-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + let ext = make_full_extensions(); + + c.bench_function("compute_wasm", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&ext); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +// --------------------------------------------------------------------------- +// Benchmark: Custom Payload vs Structured Payload +// --------------------------------------------------------------------------- + +fn bench_custom_payload_wasm(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = tool_invoke_checker_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_custom_payload_wasm: tool-invoke-checker.wasm not found"); + return; + } + + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "custom-payload-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let custom_payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + + c.bench_function("custom_payload_wasm", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let (type_name, bytes) = registry.serialize(&custom_payload).unwrap(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Custom( + cpex_wasm_host::sandbox_manager::types::CustomPayload { + payload_type: type_name.to_string(), + payload_data: bytes, + }, + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +fn bench_structured_payload_wasm(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = noop_wasm_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_structured_payload_wasm: noop.wasm not found"); + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "structured-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + + c.bench_function("structured_payload_wasm", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +// --------------------------------------------------------------------------- +// Benchmark: Mutex Contention (concurrent access) +// --------------------------------------------------------------------------- + +fn bench_concurrent_contention(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = noop_wasm_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_concurrent_contention: noop.wasm not found"); + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "contention-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + let ext = Extensions::default(); + + let mut group = c.benchmark_group("concurrent_contention"); + + for num_tasks in [1, 4, 8] { + group.bench_with_input( + BenchmarkId::from_parameter(num_tasks), + &num_tasks, + |b, &n| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let payload = payload.clone(); + let ext = ext.clone(); + async move { + let mut handles = Vec::with_capacity(n); + for _ in 0..n { + let sandbox = sandbox.clone(); + let payload = payload.clone(); + let ext = ext.clone(); + handles.push(tokio::spawn(async move { + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&ext); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + })); + } + for h in handles { + h.await.unwrap(); + } + } + }); + }, + ); + } + group.finish(); +} + +// --------------------------------------------------------------------------- +// Criterion groups +// --------------------------------------------------------------------------- + +criterion_group!( + benches, + bench_cold_start, + bench_compute_native, + bench_compute_wasm, + bench_custom_payload_wasm, + bench_structured_payload_wasm, + bench_concurrent_contention, +); +criterion_main!(benches); diff --git a/crates/cpex-wasm-host/benchmarking/invocation.rs b/crates/cpex-wasm-host/benchmarking/invocation.rs new file mode 100644 index 00000000..8e05c398 --- /dev/null +++ b/crates/cpex-wasm-host/benchmarking/invocation.rs @@ -0,0 +1,266 @@ +// Location: ./crates/cpex-wasm-host/benchmarking/invocation.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Performance benchmarks: WASM plugin invocation vs native handler. +//! +//! Measures the isolation tax of running the same logic through the +//! WASM sandbox vs calling the handler directly. +//! +//! Run: cargo bench -p cpex-wasm-host + +use std::path::PathBuf; +use std::sync::Arc; + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use tokio::runtime::Runtime; +use tokio::sync::Mutex; + +use async_trait::async_trait; +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::message::MessagePayload; +use cpex_core::cmf::{CmfHook, ContentPart, Message, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::http::HttpExtension; +use cpex_core::extensions::request::RequestExtension; +use cpex_core::extensions::security::SecurityExtension; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +// --------------------------------------------------------------------------- +// Native noop handler (baseline) +// --------------------------------------------------------------------------- + +struct NativeNoopPlugin; + +static BENCH_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for NativeNoopPlugin { + fn config(&self) -> &PluginConfig { + BENCH_CONFIG.get_or_init(|| PluginConfig { + name: "native-noop-bench".into(), + kind: "builtin".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + ..Default::default() + }) + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for NativeNoopPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_data".into(), + arguments: [("id".to_string(), serde_json::json!(42))].into(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +fn make_minimal_extensions() -> Extensions { + Extensions::default() +} + +fn make_full_extensions() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR_DATA"); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer eyJ..."); + http.set_header("X-Request-ID", "req-abc-123"); + http.set_header("Content-Type", "application/json"); + + Extensions { + request: Some(Arc::new(RequestExtension { + request_id: Some("req-abc-123".into()), + environment: Some("production".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + ..Default::default() + } +} + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +fn bench_native_noop(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let payload = make_payload(); + let ext = make_minimal_extensions(); + + c.bench_function("native_noop", |b| { + b.to_async(&rt).iter(|| async { + let mut ctx = PluginContext::default(); + let result = NativeNoopPlugin + .handle(black_box(&payload), black_box(&ext), &mut ctx) + .await; + black_box(result); + }); + }); +} + +fn bench_wasm_noop(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/noop.wasm"); + + if !wasm_path.exists() { + eprintln!( + "SKIP: noop.wasm not found at {}. Build with:", + wasm_path.display() + ); + eprintln!(" cd crates/cpex-wasm-plugin && cargo build --target wasm32-wasip2 --release --features noop --no-default-features"); + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "noop-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + let ext = make_minimal_extensions(); + + c.bench_function("wasm_noop", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&ext); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +fn bench_wasm_with_extensions(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/noop.wasm"); + + if !wasm_path.exists() { + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "noop-bench-ext") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + let ext = make_full_extensions(); + + c.bench_function("wasm_with_full_extensions", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&ext); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +fn bench_conversion_only(c: &mut Criterion) { + let payload = make_payload(); + let ext = make_full_extensions(); + let ctx = PluginContext::default(); + + c.bench_function("conversion_native_to_wit", |b| { + b.iter(|| { + let _wp = native_payload_to_wit(black_box(&payload)); + let _we = native_extensions_to_wit(black_box(&ext)); + let _wc = native_context_to_wit(black_box(&ctx)); + }); + }); +} + +fn bench_native_with_extensions(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let payload = make_payload(); + let ext = make_full_extensions(); + + c.bench_function("native_with_full_extensions", |b| { + b.to_async(&rt).iter(|| async { + let mut ctx = PluginContext::default(); + let result = NativeNoopPlugin + .handle(black_box(&payload), black_box(&ext), &mut ctx) + .await; + black_box(result); + }); + }); +} + +criterion_group!( + benches, + bench_native_noop, + bench_native_with_extensions, + bench_conversion_only, + bench_wasm_noop, + bench_wasm_with_extensions, +); +criterion_main!(benches); diff --git a/crates/cpex-wasm-host/benchmarking/performance_comparison.png b/crates/cpex-wasm-host/benchmarking/performance_comparison.png new file mode 100644 index 00000000..78556d75 Binary files /dev/null and b/crates/cpex-wasm-host/benchmarking/performance_comparison.png differ diff --git a/crates/cpex-wasm-host/benchmarking/plot_results.py b/crates/cpex-wasm-host/benchmarking/plot_results.py new file mode 100644 index 00000000..2cf0bb6c --- /dev/null +++ b/crates/cpex-wasm-host/benchmarking/plot_results.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +# Location: ./crates/cpex-wasm-host/benchmarking/plot_results.py +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Shriti Priya +""" +Performance comparison chart generator for CPEX WASM benchmarks. + +Reads Criterion JSON output and produces a grouped bar chart comparing +native vs WASM performance across scenarios. + +Usage: + cd crates/cpex-wasm-host/benchmarking + python3 plot_results.py + +Requires: matplotlib (pip install matplotlib) + +Output: performance_comparison.png +""" + +import json +import os +import sys +from pathlib import Path + +try: + import matplotlib.pyplot as plt + import matplotlib.ticker as ticker +except ImportError: + print("Error: matplotlib is required. Install with: pip install matplotlib") + sys.exit(1) + + +def find_criterion_dir(): + """Find the Criterion output directory.""" + candidates = [ + Path("../../../target/criterion"), + Path("../../target/criterion"), + Path("target/criterion"), + ] + for c in candidates: + if c.exists(): + return c.resolve() + return None + + +def read_estimate(criterion_dir, bench_name): + """Read the median estimate (in nanoseconds) for a benchmark.""" + estimate_path = criterion_dir / bench_name / "new" / "estimates.json" + if not estimate_path.exists(): + # Try group format (concurrent_contention/N) + return None + with open(estimate_path) as f: + data = json.load(f) + return data["median"]["point_estimate"] + + +def read_group_estimates(criterion_dir, group_name): + """Read estimates for a benchmark group (e.g., concurrent_contention/1, /4, /8).""" + results = {} + group_dir = criterion_dir / group_name + if not group_dir.exists(): + return results + for entry in sorted(group_dir.iterdir()): + if entry.is_dir() and entry.name != "report": + estimate_path = entry / "new" / "estimates.json" + if estimate_path.exists(): + with open(estimate_path) as f: + data = json.load(f) + results[entry.name] = data["median"]["point_estimate"] + return results + + +def format_time(ns): + """Format nanoseconds to human-readable string.""" + if ns >= 1_000_000: + return f"{ns / 1_000_000:.1f} ms" + elif ns >= 1_000: + return f"{ns / 1_000:.1f} µs" + else: + return f"{ns:.0f} ns" + + +def main(): + criterion_dir = find_criterion_dir() + if criterion_dir is None: + print("Error: Criterion output not found. Run benchmarks first:") + print(" cargo bench -p cpex-wasm-host") + sys.exit(1) + + print(f"Reading from: {criterion_dir}\n") + + # --- Collect results --- + benchmarks = { + # From invocation.rs + "native_noop": read_estimate(criterion_dir, "native_noop"), + "native_with_full_extensions": read_estimate(criterion_dir, "native_with_full_extensions"), + "conversion_native_to_wit": read_estimate(criterion_dir, "conversion_native_to_wit"), + "wasm_noop": read_estimate(criterion_dir, "wasm_noop"), + "wasm_with_full_extensions": read_estimate(criterion_dir, "wasm_with_full_extensions"), + # From comprehensive.rs + "cold_start_wasm": read_estimate(criterion_dir, "cold_start_wasm"), + "compute_native": read_estimate(criterion_dir, "compute_native"), + "compute_wasm": read_estimate(criterion_dir, "compute_wasm"), + "custom_payload_wasm": read_estimate(criterion_dir, "custom_payload_wasm"), + "structured_payload_wasm": read_estimate(criterion_dir, "structured_payload_wasm"), + } + + contention = read_group_estimates(criterion_dir, "concurrent_contention") + + # Filter out None values + available = {k: v for k, v in benchmarks.items() if v is not None} + + if not available: + print("Error: No benchmark results found. Run benchmarks first:") + print(" cargo bench -p cpex-wasm-host") + sys.exit(1) + + # --- Print ASCII table --- + print("=" * 70) + print(f"{'Benchmark':<35} {'Latency':>12} {'vs Native':>12}") + print("-" * 70) + + native_noop = available.get("native_noop", 84) + for name, ns in sorted(available.items(), key=lambda x: x[1]): + ratio = ns / native_noop if native_noop else 0 + print(f" {name:<33} {format_time(ns):>12} {ratio:>10.1f}x") + + if contention: + print() + print(" Concurrent contention:") + for tasks, ns in sorted(contention.items(), key=lambda x: int(x[0])): + print(f" {tasks} tasks: {format_time(ns):>12}") + + print("=" * 70) + + # --- Generate chart --- + fig, axes = plt.subplots(1, 3, figsize=(16, 6)) + fig.suptitle("CPEX WASM Plugin Performance", fontsize=14, fontweight="bold") + + # Panel 1: "How much slower is WASM than native?" + ax1 = axes[0] + comparison_pairs = [] + + if "native_noop" in available and "wasm_noop" in available: + comparison_pairs.append(("Noop\n(sandbox overhead)", available["native_noop"], available["wasm_noop"])) + if "compute_native" in available and "compute_wasm" in available: + comparison_pairs.append(("Real Work\n(JSON + hash)", available["compute_native"], available["compute_wasm"])) + if "native_with_full_extensions" in available and "wasm_with_full_extensions" in available: + comparison_pairs.append(("Full Extensions\n(production-like)", available["native_with_full_extensions"], available["wasm_with_full_extensions"])) + + if comparison_pairs: + labels = [p[0] for p in comparison_pairs] + native_vals = [p[1] / 1000 for p in comparison_pairs] # convert to µs + wasm_vals = [p[2] / 1000 for p in comparison_pairs] + + x = range(len(labels)) + width = 0.35 + bars1 = ax1.bar([i - width/2 for i in x], native_vals, width, label="Native", color="#2196F3", alpha=0.85) + bars2 = ax1.bar([i + width/2 for i in x], wasm_vals, width, label="WASM", color="#FF9800", alpha=0.85) + + ax1.set_ylabel("Latency (µs, log scale)") + ax1.set_title("Native vs WASM\n(same workload, lower is better)") + ax1.set_xticks(list(x)) + ax1.set_xticklabels(labels, fontsize=9) + ax1.set_yscale("log") + ax1.legend(loc="upper left") + ax1.grid(axis="y", alpha=0.3) + + for bar in bars1: + h = bar.get_height() + ax1.annotate(f"{h:.2f}µs", xy=(bar.get_x() + bar.get_width()/2, h), + xytext=(0, 3), textcoords="offset points", ha="center", fontsize=8) + for bar in bars2: + h = bar.get_height() + ax1.annotate(f"{h:.1f}µs", xy=(bar.get_x() + bar.get_width()/2, h), + xytext=(0, 3), textcoords="offset points", ha="center", fontsize=8) + + # Panel 2: "Custom JSON payload vs structured WIT payload + cold start" + ax2 = axes[1] + payload_bars = [] + + if "custom_payload_wasm" in available: + payload_bars.append(("Custom Payload\n(JSON serde)", available["custom_payload_wasm"] / 1000, "#FF9800")) + if "structured_payload_wasm" in available: + payload_bars.append(("Structured Payload\n(WIT types)", available["structured_payload_wasm"] / 1000, "#4CAF50")) + if "cold_start_wasm" in available: + payload_bars.append(("Cold Start\n(first load + compile)", available["cold_start_wasm"] / 1000, "#F44336")) + + if payload_bars: + labels = [b[0] for b in payload_bars] + vals = [b[1] for b in payload_bars] + colors = [b[2] for b in payload_bars] + + bars = ax2.bar(range(len(labels)), vals, color=colors, alpha=0.85) + ax2.set_ylabel("Latency (µs, log scale)") + ax2.set_title("Payload Path Cost & Cold Start\n(one-time vs per-call)") + ax2.set_xticks(range(len(labels))) + ax2.set_xticklabels(labels, fontsize=9) + ax2.set_yscale("log") + ax2.grid(axis="y", alpha=0.3) + + for bar in bars: + h = bar.get_height() + if h > 1000: + label = f"{h/1000:.0f}ms" + else: + label = f"{h:.1f}µs" + ax2.annotate(label, xy=(bar.get_x() + bar.get_width()/2, h), + xytext=(0, 3), textcoords="offset points", ha="center", fontsize=9, fontweight="bold") + + # Panel 3: "How does mutex contention scale?" + ax3 = axes[2] + if contention: + tasks = sorted(contention.keys(), key=int) + vals = [contention[t] / 1000 for t in tasks] # convert to µs + per_call = [contention[t] / 1000 / int(t) for t in tasks] # per-call latency + + ax3.bar(range(len(tasks)), vals, color="#9C27B0", alpha=0.85, label="Total (all tasks)") + ax3.plot(range(len(tasks)), per_call, "o-", color="#E91E63", linewidth=2, markersize=8, label="Per-call effective") + + ax3.set_ylabel("Latency (µs)") + ax3.set_title("Mutex Contention\n(N tasks sharing 1 sandbox)") + ax3.set_xticks(range(len(tasks))) + ax3.set_xticklabels([f"{t} tasks" for t in tasks], fontsize=9) + ax3.legend(loc="upper left") + ax3.grid(axis="y", alpha=0.3) + + for i, (v, pc) in enumerate(zip(vals, per_call)): + ax3.annotate(f"{v:.0f}µs", xy=(i, v), + xytext=(0, 3), textcoords="offset points", ha="center", fontsize=8) + ax3.annotate(f"{pc:.0f}µs/call", xy=(i, pc), + xytext=(5, -10), textcoords="offset points", ha="left", fontsize=8, color="#E91E63") + + plt.tight_layout() + output_path = Path(__file__).parent / "performance_comparison.png" + plt.savefig(output_path, dpi=150, bbox_inches="tight") + print(f"\nChart saved: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/crates/cpex-wasm-host/config/config_capabilities.yaml b/crates/cpex-wasm-host/config/config_capabilities.yaml new file mode 100644 index 00000000..110a02e0 --- /dev/null +++ b/crates/cpex-wasm-host/config/config_capabilities.yaml @@ -0,0 +1,83 @@ +# WASM Capabilities Demo Configuration +# +# Three WASM plugins with different capabilities see different views +# of the same extensions. Demonstrates capability-gated access +# across pre-invoke and post-invoke hooks with sandboxed WASM plugins. + +plugin_settings: + routing_enabled: true + +global: + policies: + all: + plugins: [identity-checker, header-injector, audit-logger] + +plugins: + - name: identity-checker + kind: wasm://identity-checker.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: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: header-injector + kind: wasm://header-injector.wasm + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + capabilities: + - read_headers + - write_headers + - append_labels + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: audit-logger + kind: wasm://audit-logger.wasm + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: audit + priority: 100 + on_error: ignore + capabilities: + - read_headers + - read_labels + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + +routes: + - tool: "*" + plugins: [] diff --git a/crates/cpex-wasm-host/config/config_env_sandbox_demo.yaml b/crates/cpex-wasm-host/config/config_env_sandbox_demo.yaml new file mode 100644 index 00000000..3d767764 --- /dev/null +++ b/crates/cpex-wasm-host/config/config_env_sandbox_demo.yaml @@ -0,0 +1,67 @@ +# Env Sandbox Permissions Demo Configuration +# +# Demonstrates WASI environment variable sandbox enforcement. +# One plugin (env-sandbox-demo) handles every route. The caller passes +# "env_var" as a tool argument; the plugin calls std::env::var and returns +# ALLOW if the variable is visible inside the sandbox or DENY if it is not. +# +# How env enforcement works: +# - The host process exposes ALL env vars to its own process. +# - build_wasi_context iterates allowed_env and calls builder.env(key, val) +# for each variable found on the host. +# - Inside the WASM sandbox, std::env::var only sees variables explicitly +# injected this way. Nothing leaks implicitly. +# - A variable missing from allowed_env (or not set on the host) returns +# Err(NotPresent) inside the sandbox. +# +# Scenarios demonstrated: +# +# CPEX_APP_TOKEN — explicitly allowed; plugin can read its value → ALLOW +# CPEX_LOG_LEVEL — explicitly allowed; plugin can read its value → ALLOW +# HOME — not in allowed_env; hidden from sandbox → DENY +# PATH — not in allowed_env; hidden from sandbox → DENY +# SECRET_API_KEY — sensitive; not in allowed_env; hidden from sandbox → DENY + +plugin_settings: + routing_enabled: true + +plugins: + - name: env-sandbox-demo + kind: wasm://env-sandbox-demo.wasm + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: + - CPEX_APP_TOKEN # application token — intentionally exposed to the plugin + - CPEX_LOG_LEVEL # log level config — intentionally exposed to the plugin + # HOME, PATH, SECRET_API_KEY are deliberately absent + resources: + max_memory_bytes: 10485760 # 10 MB + max_fuel: 1000000000 # 1 B instructions + max_execution_time_ms: 5000 # 5 s per invocation + max_instances: 10 + max_tables: 10 + +routes: + - tool: env_allowed_token + plugins: [env-sandbox-demo] + + - tool: env_allowed_log_level + plugins: [env-sandbox-demo] + + - tool: env_denied_home + plugins: [env-sandbox-demo] + + - tool: env_denied_path + plugins: [env-sandbox-demo] + + - tool: env_denied_secret + plugins: [env-sandbox-demo] + + - tool: "*" + plugins: [env-sandbox-demo] diff --git a/crates/cpex-wasm-host/config/config_execution_modes_test.yaml b/crates/cpex-wasm-host/config/config_execution_modes_test.yaml new file mode 100644 index 00000000..e07a3c45 --- /dev/null +++ b/crates/cpex-wasm-host/config/config_execution_modes_test.yaml @@ -0,0 +1,58 @@ +# Execution Modes Test Configuration +# +# Tests concurrent and fire_and_forget plugin execution modes. +# Uses noop (always allow) plugins to verify pipeline behavior. + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +plugins: + - name: concurrent-a + kind: wasm://noop.wasm + hooks: [cmf.tool_pre_invoke] + mode: concurrent + priority: 10 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_fuel: 1000000000 + max_execution_time_ms: 5000 + + - name: concurrent-b + kind: wasm://noop.wasm + hooks: [cmf.tool_pre_invoke] + mode: concurrent + priority: 20 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_fuel: 1000000000 + max_execution_time_ms: 5000 + + - name: fire-and-forget-a + kind: wasm://noop.wasm + hooks: [cmf.tool_post_invoke] + mode: fire_and_forget + priority: 10 + on_error: ignore + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_fuel: 1000000000 + max_execution_time_ms: 5000 + +routes: + - tool: "*" + plugins: [] diff --git a/crates/cpex-wasm-host/config/config_fs_sandbox_demo.yaml b/crates/cpex-wasm-host/config/config_fs_sandbox_demo.yaml new file mode 100644 index 00000000..59048da9 --- /dev/null +++ b/crates/cpex-wasm-host/config/config_fs_sandbox_demo.yaml @@ -0,0 +1,119 @@ +# Filesystem Sandbox Permissions Demo Configuration +# +# Demonstrates all six WASI filesystem permission levels from policy_loader.rs. +# One plugin (fs-sandbox-demo) handles every route. The caller passes +# "operation" and "path" as tool arguments; the plugin attempts the operation +# and returns ALLOW if WASI permits it or DENY if the sandbox blocks it. +# +# Permission levels and what they enforce: +# +# read-only DirPerms::READ, FilePerms::READ +# → can list + read; write/create/delete denied +# +# full-access DirPerms::READ | MUTATE, FilePerms::READ | WRITE +# → all operations permitted +# +# drop-box DirPerms::MUTATE, FilePerms::WRITE +# → write only; list_dir and read denied +# +# fixed-mutable DirPerms::READ, FilePerms::READ | WRITE +# → read + overwrite existing files; create_dir denied +# +# list-only DirPerms::READ, FilePerms::empty() +# → list directory only; read/write file contents denied +# +# private-scratch DirPerms::MUTATE, FilePerms::READ | WRITE +# → full file I/O; list_dir denied +# +# Data directories (pre-created under examples/data/): +# rules/ — seeded with policy.yaml (read-only scenario) +# cache/ — empty (full-access scenario) +# audit/ — empty (drop-box scenario) +# counters/ — seeded with rate.txt (fixed-mutable scenario) +# plugins/ — seeded with a stub .wasm (list-only scenario) +# scratch/ — empty (private-scratch scenario) + +plugin_settings: + routing_enabled: true + +plugins: + - name: fs-sandbox-demo + kind: wasm://fs-sandbox-demo.wasm + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: + # read-only: list + read files; write/create/delete blocked by WASI + - dir: examples/data/rules + permission: read-only + + # full-access: read, write, create dirs, delete — all permitted + - dir: examples/data/cache + permission: full-access + + # drop-box: write files only; list_dir and read denied by WASI + - dir: examples/data/audit + permission: drop-box + + # fixed-mutable: read + overwrite existing files; create_dir denied + - dir: examples/data/counters + permission: fixed-mutable + + # list-only: enumerate filenames only; opening file contents denied + - dir: examples/data/plugins + permission: list-only + + # private-scratch: write + read files; list_dir denied by WASI + - dir: examples/data/scratch + permission: private-scratch + + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 # 10 MB + max_fuel: 1000000000 # 1 B instructions + max_execution_time_ms: 5000 # 5 s per invocation + max_instances: 10 + max_tables: 10 + +# Each route targets one permission scenario. +# Send the matching operation + path in the tool arguments to trigger it. +# +# Examples: +# read-only → operation: read, path: examples/data/rules/policy.yaml → ALLOW +# read-only → operation: write, path: examples/data/rules/policy.yaml → DENY +# full-access → operation: write, path: examples/data/cache/output.txt → ALLOW +# drop-box → operation: write, path: examples/data/audit/event.log → ALLOW +# drop-box → operation: read, path: examples/data/audit/event.log → DENY +# drop-box → operation: list_dir, path: examples/data/audit → DENY +# fixed-mutable→ operation: write, path: examples/data/counters/rate.txt → ALLOW +# fixed-mutable→ operation: create_dir, path: examples/data/counters/new → DENY +# list-only → operation: list_dir, path: examples/data/plugins → ALLOW +# list-only → operation: read, path: examples/data/plugins/fs-sandbox-demo.wasm → DENY +# private-scratch → operation: write, path: examples/data/scratch/tmp.bin → ALLOW +# private-scratch → operation: list_dir,path: examples/data/scratch → DENY + +routes: + - tool: fs_read_only + plugins: [fs-sandbox-demo] + + - tool: fs_full_access + plugins: [fs-sandbox-demo] + + - tool: fs_drop_box + plugins: [fs-sandbox-demo] + + - tool: fs_fixed_mutable + plugins: [fs-sandbox-demo] + + - tool: fs_list_only + plugins: [fs-sandbox-demo] + + - tool: fs_private_scratch + plugins: [fs-sandbox-demo] + + - tool: "*" + plugins: [fs-sandbox-demo] diff --git a/crates/cpex-wasm-host/config/config_identity_resolve_test.yaml b/crates/cpex-wasm-host/config/config_identity_resolve_test.yaml new file mode 100644 index 00000000..1e4ba374 --- /dev/null +++ b/crates/cpex-wasm-host/config/config_identity_resolve_test.yaml @@ -0,0 +1,28 @@ +# Identity Resolve Test Configuration +# +# One WASM plugin (identity-checker) registered for the "identity.resolve" hook. +# The host sends an IdentityPayload with request headers; the plugin resolves +# the subject from the x-user-id header. + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +plugins: + - name: identity-checker + kind: wasm://identity-checker.wasm + hooks: [identity.resolve] + mode: sequential + priority: 10 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 diff --git a/crates/cpex-wasm-host/config/config_plugin_demo.yaml b/crates/cpex-wasm-host/config/config_plugin_demo.yaml new file mode 100644 index 00000000..5287328d --- /dev/null +++ b/crates/cpex-wasm-host/config/config_plugin_demo.yaml @@ -0,0 +1,121 @@ +# WASM Plugin Demo Configuration +# +# Four WASM plugins using custom ToolInvokePayload, policy groups with +# tag-based activation, and routes that map tools to different plugin +# combinations. Mirrors the native cpex-core/examples/plugin_demo.yaml. + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + # "all" is reserved — these plugins fire on every invocation + all: + plugins: [identity-resolver] + # "pii" group — activated when a route has the "pii" tag + pii: + plugins: [pii-guard] + # "external_authz" group — activated when a route has the + # "needs_remote_authz" tag. Fires the RemoteAuthz WASM plugin. + external_authz: + plugins: [remote-authz] + +plugins: + - name: identity-resolver + kind: wasm://tool-invoke-checker.wasm + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: pii-guard + kind: wasm://pii-guard.wasm + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: remote-authz + kind: wasm://remote-authz.wasm + hooks: [tool_pre_invoke] + mode: sequential + priority: 30 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: audit-logger + kind: wasm://audit-logger-custom.wasm + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + +routes: + # HR compensation tool — contains PII, gets full security stack + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + # Public department listing — standard security only + - tool: list_departments + plugins: + - audit-logger + + # Tool that requires remote authz — triggers the async plugin + # on top of the standard "all" policy stack. + - tool: query_external_data + meta: + tags: [external_authz] + plugins: + - audit-logger + + # Wildcard — catch-all for unmatched tools + - tool: "*" + plugins: + - audit-logger diff --git a/crates/cpex-wasm-host/config/config_policy_test_fixture.yaml b/crates/cpex-wasm-host/config/config_policy_test_fixture.yaml new file mode 100644 index 00000000..d947f077 --- /dev/null +++ b/crates/cpex-wasm-host/config/config_policy_test_fixture.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, tool_pre_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 (lifetime of the store) + max_fuel: 1000000000 # ~1 billion instructions per invocation (reset each call) + max_execution_time_ms: 5000 # 5 seconds wall-clock timeout per invocation + max_instances: 10 # Max WASM component instances in the store (reserved; current plugins use 1) + max_tables: 10 # Max WASM tables in the store (reserved; current plugins use 1) + +routes: + - tool: "*" + plugins: [] diff --git a/crates/cpex-wasm-host/config/config_token_attenuator_demo.yaml b/crates/cpex-wasm-host/config/config_token_attenuator_demo.yaml new file mode 100644 index 00000000..f6280d90 --- /dev/null +++ b/crates/cpex-wasm-host/config/config_token_attenuator_demo.yaml @@ -0,0 +1,28 @@ +# Token Attenuator Demo Configuration +# +# One WASM plugin (token-attenuator) registered for the "token.delegate" hook. +# The host constructs a DelegationPayload and fires invoke_named — the plugin +# mints a scoped outbound credential for each tool target. + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +plugins: + - name: token-attenuator + kind: wasm://token-attenuator.wasm + hooks: [token.delegate] + mode: sequential + priority: 10 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 diff --git a/crates/cpex-wasm-host/examples/data/cache/output.txt b/crates/cpex-wasm-host/examples/data/cache/output.txt new file mode 100644 index 00000000..9a9cc309 --- /dev/null +++ b/crates/cpex-wasm-host/examples/data/cache/output.txt @@ -0,0 +1 @@ +fs-sandbox-demo probe diff --git a/crates/cpex-wasm-host/examples/data/counters/rate.txt b/crates/cpex-wasm-host/examples/data/counters/rate.txt new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/crates/cpex-wasm-host/examples/data/counters/rate.txt @@ -0,0 +1 @@ +0 diff --git a/crates/cpex-wasm-host/examples/data/input.txt b/crates/cpex-wasm-host/examples/data/input.txt new file mode 100644 index 00000000..355d1791 --- /dev/null +++ b/crates/cpex-wasm-host/examples/data/input.txt @@ -0,0 +1 @@ +"Hello xyz" \ No newline at end of file diff --git a/crates/cpex-wasm-host/examples/data/plugins/fs-sandbox-demo.wasm b/crates/cpex-wasm-host/examples/data/plugins/fs-sandbox-demo.wasm new file mode 100644 index 00000000..e69de29b diff --git a/crates/cpex-wasm-host/examples/data/rules/policy.yaml b/crates/cpex-wasm-host/examples/data/rules/policy.yaml new file mode 100644 index 00000000..7ea7e7ef --- /dev/null +++ b/crates/cpex-wasm-host/examples/data/rules/policy.yaml @@ -0,0 +1 @@ +allow_tool: "*" diff --git a/crates/cpex-wasm-host/examples/data/secrets.txt b/crates/cpex-wasm-host/examples/data/secrets.txt new file mode 100644 index 00000000..d098c93a --- /dev/null +++ b/crates/cpex-wasm-host/examples/data/secrets.txt @@ -0,0 +1 @@ +"i am dummy secret" \ No newline at end of file diff --git a/crates/cpex-wasm-host/examples/wasm_capabilities_demo.rs b/crates/cpex-wasm-host/examples/wasm_capabilities_demo.rs new file mode 100644 index 00000000..f3db6c54 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_capabilities_demo.rs @@ -0,0 +1,232 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_capabilities_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WASM Capabilities Demo +// +// Demonstrates: +// 1. Three WASM plugins with different capability profiles +// 2. Capability-gated extension visibility across the WASM sandbox boundary +// 3. Extension modification by a WASM plugin (add label + inject header) +// 4. Multi-plugin pipeline with priority ordering +// +// Prerequisites: build all plugin binaries: +// cd crates/cpex-wasm-plugin && make build-all +// +// Run: +// cargo run -p cpex-wasm-host --example wasm_capabilities_demo + +use std::path::PathBuf; +use std::sync::Arc; + +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall, ToolResult}; +use cpex_core::config::parse_config; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::http::HttpExtension; +use cpex_core::extensions::meta::MetaExtension; +use cpex_core::extensions::request::RequestExtension; +use cpex_core::extensions::security::{SecurityExtension, SubjectExtension, SubjectType}; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; + +const CYAN_BOLD: &str = "\x1b[1;36m"; +const WHITE: &str = "\x1b[97m"; +const RED: &str = "\x1b[31m"; +const RESET: &str = "\x1b[0m"; + +macro_rules! scenario { + ($($arg:tt)*) => { + println!("{}{}{}", CYAN_BOLD, format!($($arg)*), RESET) + }; +} + +#[tokio::main] +async fn main() { + // Initialize tracing so plugin cpex_log! calls are visible. + // Set RUST_LOG=info (or debug/trace) to control verbosity. + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("info".parse().unwrap()), + ) + .init(); + + println!("=== WASM Capabilities Demo ===\n"); + + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_capabilities.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + println!("Loading config: {}", 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(); + + // Register the WASM factory for each plugin kind. + // The factory strips "wasm://" and looks for the .wasm file in the wasm/ dir. + mgr.register_factory( + "wasm://identity-checker.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(wasm_dir.clone())), + ); + mgr.register_factory( + "wasm://header-injector.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(wasm_dir.clone())), + ); + mgr.register_factory( + "wasm://audit-logger.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(wasm_dir)), + ); + + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // --- Build CMF Message: assistant requesting a tool call --- + let pre_payload = MessagePayload { + message: Message { + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ + ContentPart::Text { + text: "Looking up compensation data.".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, + }, + }; + + let ext = build_extensions(); + + // --- Phase 1: Pre-invoke --- + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + scenario!("=== Phase 1: cmf.tool_pre_invoke ===\n"); + + let (pre_result, pre_bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", pre_payload, ext, None) + .await; + + println!(); + if pre_result.continue_processing { + println!("{}Pre-invoke result: ALLOWED{}", WHITE, RESET); + 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 { + let reason = pre_result + .violation + .as_ref() + .map(|v| v.reason.as_str()) + .unwrap_or("unknown"); + println!("{}Pre-invoke result: DENIED — {}{}", RED, reason, RESET); + pre_bg.wait_for_background_tasks().await; + println!("\n=== Demo complete ==="); + return; + } + pre_bg.wait_for_background_tasks().await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // --- Simulate tool execution --- + println!("\n--- Tool 'get_compensation' executes... ---"); + println!(" Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n"); + + // --- Phase 2: Post-invoke with tool result --- + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + scenario!("=== 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: 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, + }, + }; + + let post_ext = pre_result + .modified_extensions + .unwrap_or_else(build_extensions); + + 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{}", WHITE, RESET); + } else { + let reason = post_result + .violation + .as_ref() + .map(|v| v.reason.as_str()) + .unwrap_or("unknown"); + println!("{}Post-invoke result: DENIED — {}{}", RED, reason, RESET); + } + + post_bg.wait_for_background_tasks().await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + println!("\n=== Demo complete ==="); +} + +fn build_extensions() -> Extensions { + 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(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"); + + 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() + } +} diff --git a/crates/cpex-wasm-host/examples/wasm_env_sandbox_demo.rs b/crates/cpex-wasm-host/examples/wasm_env_sandbox_demo.rs new file mode 100644 index 00000000..3b474337 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_env_sandbox_demo.rs @@ -0,0 +1,183 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_env_sandbox_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Environment Variable Sandbox Permissions Demo +// +// Invokes env-sandbox-demo.wasm with "env_var" as a ToolCall argument. +// The plugin calls std::env::var inside the WASM sandbox and returns: +// ALLOW — variable was declared in allowed_env and is visible +// DENY — variable was not declared; sandbox hides it +// +// How enforcement works: +// build_wasi_context injects only the variables listed in allowed_env into +// the WasiCtx via builder.env(key, val). The sandbox never calls +// inherit_env(), so no host variable leaks implicitly. std::env::var inside +// the plugin only sees what was explicitly injected. +// +// Run: +// cd crates/cpex-wasm-host && make run-env-demo + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::config::parse_config; +use cpex_core::executor::PipelineResult; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::meta::MetaExtension; +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +struct CmfPreInvoke; +impl HookTypeDef for CmfPreInvoke { + type Payload = MessagePayload; + type Result = PluginResult; + const NAME: &'static str = "cmf.tool_pre_invoke"; +} + +const BOLD: &str = "\x1b[1m"; +const CYAN: &str = "\x1b[1;36m"; +const GREEN: &str = "\x1b[32m"; +const RED: &str = "\x1b[31m"; +const DIM: &str = "\x1b[2m"; +const RESET: &str = "\x1b[0m"; + +fn make_payload(env_var: &str) -> MessagePayload { + let mut arguments = HashMap::new(); + arguments.insert("env_var".to_string(), serde_json::json!(env_var)); + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: format!("tc_env_{}", env_var), + name: "env_sandbox_demo".into(), + arguments, + namespace: None, + }, + }], + channel: None, + }, + } +} + +fn make_extensions() -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("env_sandbox_demo".into()), + ..Default::default() + })), + ..Default::default() + } +} + +async fn invoke(mgr: &PluginManager, env_var: &str) -> PipelineResult { + let (result, bg) = mgr + .invoke::(make_payload(env_var), make_extensions(), None) + .await; + bg.wait_for_background_tasks().await; + result +} + +fn print_case(label: &str, env_var: &str, result: &PipelineResult) { + if result.continue_processing { + println!( + " {}[{}]{} env_var={}\n {}→ ALLOW{}", + DIM, label, RESET, env_var, GREEN, RESET, + ); + } else { + let code = result + .violation + .as_ref() + .map(|v| v.code.as_str()) + .unwrap_or("unknown"); + println!( + " {}[{}]{} env_var={}\n {}→ DENY [{}]{}", + DIM, label, RESET, env_var, RED, code, RESET, + ); + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("warn".parse().unwrap()), + ) + .init(); + + // Set the env vars the demo will probe. In a real deployment these would + // already be present in the host process environment. + std::env::set_var("CPEX_APP_TOKEN", "tok-demo-abc123"); + std::env::set_var("CPEX_LOG_LEVEL", "info"); + std::env::set_var("SECRET_API_KEY", "sk-super-secret-value"); + // HOME and PATH are already set by the shell. + + println!("{}=== Environment Variable Sandbox Permissions Demo ==={}\n", BOLD, RESET); + println!("{}Plugin:{} env-sandbox-demo.wasm", DIM, RESET); + println!("{}Payload:{} env_var passed as ToolCall argument\n", DIM, RESET); + + println!( + "{}Host env vars set for this demo:{}", + DIM, RESET + ); + println!(" CPEX_APP_TOKEN = tok-demo-abc123 (in allowed_env → visible)"); + println!(" CPEX_LOG_LEVEL = info (in allowed_env → visible)"); + println!(" HOME = {} (not in allowed_env → hidden)", + std::env::var("HOME").unwrap_or_else(|_| "".into())); + println!(" PATH = (not in allowed_env → hidden)"); + println!(" SECRET_API_KEY = sk-super-secret-* (not in allowed_env → hidden)\n"); + + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + let yaml = std::fs::read_to_string(crate_dir.join("config/config_env_sandbox_demo.yaml")) + .expect("config not found — run: cd crates/cpex-wasm-host && make run-env-demo"); + let cpex_config = parse_config(&yaml).unwrap(); + + let mut registry = PayloadSerializerRegistry::new(); + registry.register::(); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://env-sandbox-demo.wasm", + Box::new(WasmPluginFactory::new( + crate_dir.join("wasm"), + Arc::new(registry), + ).expect("engine")), + ); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // ========================================================================= + // Allowed variables — declared in allowed_env; value is visible to plugin + // ========================================================================= + println!("{}Scenario 1: allowed_env variables (visible inside sandbox){}", CYAN, RESET); + let r = invoke(&mgr, "CPEX_APP_TOKEN").await; + print_case("ALLOW expected", "CPEX_APP_TOKEN", &r); + let r = invoke(&mgr, "CPEX_LOG_LEVEL").await; + print_case("ALLOW expected", "CPEX_LOG_LEVEL", &r); + + // ========================================================================= + // Denied variables — not in allowed_env; hidden from sandbox + // Even though these are set on the host, the plugin cannot see them. + // ========================================================================= + println!("\n{}Scenario 2: variables NOT in allowed_env (hidden inside sandbox){}", CYAN, RESET); + let r = invoke(&mgr, "HOME").await; + print_case("DENY expected", "HOME", &r); + let r = invoke(&mgr, "PATH").await; + print_case("DENY expected", "PATH", &r); + let r = invoke(&mgr, "SECRET_API_KEY").await; + print_case("DENY expected", "SECRET_API_KEY", &r); + + println!("\n{}=== Demo complete ==={}\n", BOLD, RESET); + mgr.shutdown().await; +} diff --git a/crates/cpex-wasm-host/examples/wasm_fs_sandbox_demo.rs b/crates/cpex-wasm-host/examples/wasm_fs_sandbox_demo.rs new file mode 100644 index 00000000..a40d916f --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_fs_sandbox_demo.rs @@ -0,0 +1,233 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_fs_sandbox_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Filesystem Sandbox Permissions Demo +// +// Invokes fs-sandbox-demo.wasm with "operation" and "path" as ToolCall +// arguments. For each of the six WASI permission levels, two invocations are +// shown — one that the sandbox permits (ALLOW) and one it blocks (DENY). +// +// Permission levels: +// read-only DirPerms::READ FilePerms::READ +// full-access DirPerms::READ|MUTATE FilePerms::READ|WRITE +// drop-box DirPerms::MUTATE FilePerms::WRITE +// fixed-mutable DirPerms::READ FilePerms::READ|WRITE +// list-only DirPerms::READ FilePerms::empty() +// private-scratch DirPerms::MUTATE FilePerms::READ|WRITE +// +// Run: +// cd crates/cpex-wasm-host && make run-sandbox-demo + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::config::parse_config; +use cpex_core::executor::PipelineResult; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::meta::MetaExtension; +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +struct CmfPreInvoke; +impl HookTypeDef for CmfPreInvoke { + type Payload = MessagePayload; + type Result = PluginResult; + const NAME: &'static str = "cmf.tool_pre_invoke"; +} + +// --------------------------------------------------------------------------- +// Terminal colours +// --------------------------------------------------------------------------- + +const BOLD: &str = "\x1b[1m"; +const CYAN: &str = "\x1b[1;36m"; +const GREEN: &str = "\x1b[32m"; +const RED: &str = "\x1b[31m"; +const DIM: &str = "\x1b[2m"; +const RESET: &str = "\x1b[0m"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_payload(operation: &str, path: &str) -> MessagePayload { + let mut arguments = HashMap::new(); + arguments.insert("operation".to_string(), serde_json::json!(operation)); + arguments.insert("path".to_string(), serde_json::json!(path)); + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: format!("tc_{}_{}", operation, path.replace('/', "_")), + name: "fs_sandbox_demo".into(), + arguments, + namespace: None, + }, + }], + channel: None, + }, + } +} + +fn make_extensions() -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("fs_sandbox_demo".into()), + ..Default::default() + })), + ..Default::default() + } +} + +async fn invoke(mgr: &PluginManager, operation: &str, path: &str) -> PipelineResult { + let (result, bg) = mgr + .invoke::(make_payload(operation, path), make_extensions(), None) + .await; + bg.wait_for_background_tasks().await; + result +} + +fn print_case(label: &str, operation: &str, path: &str, result: &PipelineResult) { + if result.continue_processing { + println!( + " {}[{}]{} operation={:12} path={}\n {}→ ALLOW{}", + DIM, label, RESET, operation, path, GREEN, RESET, + ); + } else { + let code = result + .violation + .as_ref() + .map(|v| v.code.as_str()) + .unwrap_or("unknown"); + println!( + " {}[{}]{} operation={:12} path={}\n {}→ DENY [{}]{}", + DIM, label, RESET, operation, path, RED, code, RESET, + ); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("warn".parse().unwrap()), + ) + .init(); + + println!("{}=== Filesystem Sandbox Permissions Demo ==={}\n", BOLD, RESET); + println!( + "{}Plugin:{} fs-sandbox-demo.wasm", + DIM, RESET + ); + println!( + "{}Payload:{} operation + path passed as ToolCall arguments\n", + DIM, RESET + ); + + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + let yaml = std::fs::read_to_string(crate_dir.join("config/config_fs_sandbox_demo.yaml")) + .expect("config not found — run: make setup-sandbox-demo-data"); + let cpex_config = parse_config(&yaml).unwrap(); + + let mut registry = PayloadSerializerRegistry::new(); + registry.register::(); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://fs-sandbox-demo.wasm", + Box::new(WasmPluginFactory::new( + crate_dir.join("wasm"), + Arc::new(registry), + ).expect("engine")), + ); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // Paths must match the guest mount point registered by preopened_dir. + // The config sets dir: "examples/data/", so the plugin sees exactly + // that string — absolute host paths are invisible inside the WASM sandbox. + let p = |sub: &str, file: &str| format!("examples/data/{}/{}", sub, file); + let d = |sub: &str| format!("examples/data/{}", sub); + + // ========================================================================= + // read-only — DirPerms::READ FilePerms::READ + // ========================================================================= + println!("{}Scenario 1: read-only (rules/) DirPerms::READ FilePerms::READ{}", CYAN, RESET); + let r = invoke(&mgr, "read", &p("rules", "policy.yaml")).await; + print_case("ALLOW expected", "read", &p("rules", "policy.yaml"), &r); + let r = invoke(&mgr, "write", &p("rules", "policy.yaml")).await; + print_case("DENY expected", "write", &p("rules", "policy.yaml"), &r); + + // ========================================================================= + // full-access — DirPerms::READ|MUTATE FilePerms::READ|WRITE + // ========================================================================= + println!("\n{}Scenario 2: full-access (cache/) DirPerms::READ|MUTATE FilePerms::READ|WRITE{}", CYAN, RESET); + let r = invoke(&mgr, "write", &p("cache", "output.txt")).await; + print_case("ALLOW expected", "write", &p("cache", "output.txt"), &r); + let r = invoke(&mgr, "read", &p("cache", "output.txt")).await; + print_case("ALLOW expected", "read", &p("cache", "output.txt"), &r); + + // ========================================================================= + // drop-box — DirPerms::MUTATE FilePerms::WRITE + // open_at always requires DirPerms::READ, so file read/write are both + // denied. Only directory-level mutations (create_dir, delete) are allowed. + // ========================================================================= + println!("\n{}Scenario 3: drop-box (audit/) DirPerms::MUTATE FilePerms::WRITE{}", CYAN, RESET); + let r = invoke(&mgr, "create_dir", &p("audit", "events")).await; + print_case("ALLOW expected", "create_dir", &p("audit", "events"), &r); + let r = invoke(&mgr, "read", &p("audit", "events")).await; + print_case("DENY expected", "read", &p("audit", "events"), &r); + + // ========================================================================= + // fixed-mutable — DirPerms::READ FilePerms::READ|WRITE + // wasmtime's open_at checks DirPerms::MUTATE before FilePerms::WRITE, so + // writing a file requires MUTATE on the dir regardless. In practice this + // permission behaves like read-only at the file level — reads are allowed, + // writes and create_dir are denied. + // ========================================================================= + println!("\n{}Scenario 4: fixed-mutable (counters/) DirPerms::READ FilePerms::READ|WRITE{}", CYAN, RESET); + let r = invoke(&mgr, "read", &p("counters", "rate.txt")).await; + print_case("ALLOW expected", "read", &p("counters", "rate.txt"), &r); + let r = invoke(&mgr, "create_dir", &p("counters", "new")).await; + print_case("DENY expected", "create_dir", &p("counters", "new"), &r); + + // ========================================================================= + // list-only — DirPerms::READ FilePerms::empty() + // Can enumerate filenames. Opening any file is denied (FilePerms::empty()). + // ========================================================================= + println!("\n{}Scenario 5: list-only (plugins/) DirPerms::READ FilePerms::empty(){}", CYAN, RESET); + let r = invoke(&mgr, "list_dir", &d("plugins")).await; + print_case("ALLOW expected", "list_dir", &d("plugins"), &r); + let r = invoke(&mgr, "read", &p("plugins", "fs-sandbox-demo.wasm")).await; + print_case("DENY expected", "read", &p("plugins", "fs-sandbox-demo.wasm"), &r); + + // ========================================================================= + // private-scratch — DirPerms::MUTATE FilePerms::READ|WRITE + // open_at always requires DirPerms::READ, so file I/O is denied despite + // FilePerms::READ|WRITE. Only directory mutations are allowed. + // ========================================================================= + println!("\n{}Scenario 6: private-scratch (scratch/) DirPerms::MUTATE FilePerms::READ|WRITE{}", CYAN, RESET); + let r = invoke(&mgr, "create_dir", &p("scratch", "work")).await; + print_case("ALLOW expected", "create_dir", &p("scratch", "work"), &r); + let r = invoke(&mgr, "list_dir", &d("scratch")).await; + print_case("DENY expected", "list_dir", &d("scratch"), &r); + + println!("\n{}=== Demo complete ==={}\n", BOLD, RESET); + mgr.shutdown().await; +} diff --git a/crates/cpex-wasm-host/examples/wasm_network_policy_demo.rs b/crates/cpex-wasm-host/examples/wasm_network_policy_demo.rs new file mode 100644 index 00000000..623846d5 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_network_policy_demo.rs @@ -0,0 +1,305 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_network_policy_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Network Policy Sandbox Demo +// +// Invokes net-http-test.wasm with a URL and HTTP method as ToolCall arguments. +// Each scenario loads the plugin with a different NetworkRule configuration to +// demonstrate every enforcement dimension: +// +// 1. No policy — all outbound HTTP denied (deny-by-default) +// 2. Host allowlist — allowed host passes, unlisted host is denied +// 3. Wildcard host — *.example.com matches subdomains, not the apex +// 4. Port enforcement — only port 443 allowed; port 8080 denied +// 5. Scheme enforcement — https-only (default); plain http denied +// 6. Method enforcement — GET only; POST denied +// 7. Multi-rule — two different hosts with different constraints +// +// Prerequisites: +// cd crates/cpex-wasm-host && make build-test-plugins +// +// Run: +// cargo run -p cpex-wasm-host --example wasm_network_policy_demo + +use std::collections::HashMap; +use std::path::PathBuf; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::extensions::container::Extensions; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::policy_loader::{NetworkRule, SandboxPolicy}; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +// --------------------------------------------------------------------------- +// Terminal colours +// --------------------------------------------------------------------------- + +const BOLD: &str = "\x1b[1m"; +const CYAN: &str = "\x1b[1;36m"; +const GREEN: &str = "\x1b[32m"; +const RED: &str = "\x1b[31m"; +const DIM: &str = "\x1b[2m"; +const RESET: &str = "\x1b[0m"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/net-http-test.wasm") +} + +fn make_payload(url: &str, method: &str) -> MessagePayload { + let mut arguments = HashMap::new(); + arguments.insert("url".to_string(), serde_json::json!(url)); + arguments.insert("method".to_string(), serde_json::json!(method)); + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_net".into(), + name: "http_check".into(), + arguments, + namespace: None, + }, + }], + channel: None, + }, + } +} + +async fn invoke(mgr: &mut SandboxManager, url: &str, method: &str) -> String { + let payload = make_payload(url, method); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + result + .modified_context + .and_then(|ctx| ctx.local_state.into_iter().find(|e| e.key == "http_result")) + .map(|e| e.value.trim_matches('"').to_string()) + .unwrap_or_else(|| "no_result".to_string()) +} + +fn print_case(label: &str, method: &str, url: &str, result: &str) { + let (arrow, color) = if result == "allowed" { + ("ALLOW", GREEN) + } else { + ("DENY ", RED) + }; + println!( + " {}[{}]{} {:6} {}\n {}→ {}{}", + DIM, label, RESET, method, url, color, arrow, RESET, + ); +} + +async fn load_plugin(shared: &SharedEngine, policy: Option, name: &str) -> SandboxManager { + let path = wasm_path(); + let mut mgr = SandboxManager::with_shared_engine(shared); + mgr.load_wasmplugin(&path, policy.as_ref(), name) + .await + .unwrap_or_else(|e| panic!("failed to load plugin '{}': {}", name, e)); + mgr +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("warn".parse().unwrap()), + ) + .init(); + + let path = wasm_path(); + if !path.exists() { + eprintln!( + "{}ERROR:{} net-http-test.wasm not found at {}\n\ + Run: cd crates/cpex-wasm-host && make build-test-plugins", + RED, RESET, + path.display() + ); + std::process::exit(1); + } + + println!("{}=== Network Policy Sandbox Demo ==={}\n", BOLD, RESET); + println!( + "{}Plugin:{} net-http-test.wasm\n\ + {}Payload:{} url + method passed as ToolCall arguments\n\ + {}Result:{} plugin writes 'allowed' or 'denied' into local_state\n", + DIM, RESET, DIM, RESET, DIM, RESET + ); + + // Shared wasmtime engine — one epoch thread, each scenario gets its own store + let shared = SharedEngine::new().unwrap(); + + // ========================================================================= + // Scenario 1: No policy — deny-by-default + // Every outbound request is blocked when no allowed_network is configured. + // ========================================================================= + println!("{}--- Scenario 1: no policy (deny-by-default){}", CYAN, RESET); + let mut mgr = load_plugin(&shared, None, "net-deny-all").await; + let r = invoke(&mut mgr, "https://example.com/", "GET").await; + print_case("DENY expected", "GET", "https://example.com/", &r); + println!(); + + // ========================================================================= + // Scenario 2: Host allowlist + // example.com is explicitly allowed; other.com is not in the list. + // ========================================================================= + println!("{}--- Scenario 2: host allowlist allowed=[example.com]{}", CYAN, RESET); + let policy = SandboxPolicy { + allowed_network: vec![NetworkRule { + host: "example.com".to_string(), + ..Default::default() + }], + ..Default::default() + }; + let mut mgr = load_plugin(&shared, Some(policy), "net-allowlist").await; + let r = invoke(&mut mgr, "https://example.com/", "GET").await; + print_case("ALLOW expected", "GET", "https://example.com/", &r); + let r = invoke(&mut mgr, "https://other.com/", "GET").await; + print_case("DENY expected", "GET", "https://other.com/", &r); + println!(); + + // ========================================================================= + // Scenario 3: Wildcard host + // *.example.com matches api.example.com and data.example.com, + // but NOT the apex example.com itself. + // ========================================================================= + println!("{}--- Scenario 3: wildcard host allowed=[*.example.com]{}", CYAN, RESET); + let policy = SandboxPolicy { + allowed_network: vec![NetworkRule { + host: "*.example.com".to_string(), + ..Default::default() + }], + ..Default::default() + }; + let mut mgr = load_plugin(&shared, Some(policy), "net-wildcard").await; + let r = invoke(&mut mgr, "https://api.example.com/", "GET").await; + print_case("ALLOW expected", "GET", "https://api.example.com/", &r); + let r = invoke(&mut mgr, "https://data.example.com/", "GET").await; + print_case("ALLOW expected", "GET", "https://data.example.com/", &r); + let r = invoke(&mut mgr, "https://example.com/", "GET").await; + print_case("DENY expected", "GET", "https://example.com/", &r); + println!(); + + // ========================================================================= + // Scenario 4: Port enforcement + // Only port 443 is allowed. Port 8080 must be denied. + // ========================================================================= + println!("{}--- Scenario 4: port enforcement allowed_ports=[443]{}", CYAN, RESET); + let policy = SandboxPolicy { + allowed_network: vec![NetworkRule { + host: "example.com".to_string(), + ports: vec![443], + ..Default::default() + }], + ..Default::default() + }; + let mut mgr = load_plugin(&shared, Some(policy), "net-port").await; + let r = invoke(&mut mgr, "https://example.com/", "GET").await; + print_case("ALLOW expected", "GET", "https://example.com/ (port 443, implicit)", &r); + let r = invoke(&mut mgr, "https://example.com:8080/", "GET").await; + print_case("DENY expected", "GET", "https://example.com:8080/ (port 8080)", &r); + println!(); + + // ========================================================================= + // Scenario 5: Scheme enforcement + // Default schemes = ["https"] — plain http must be denied. + // ========================================================================= + println!("{}--- Scenario 5: scheme enforcement allowed_schemes=[https]{}", CYAN, RESET); + let policy = SandboxPolicy { + allowed_network: vec![NetworkRule { + host: "example.com".to_string(), + // schemes defaults to ["https"] + ..Default::default() + }], + ..Default::default() + }; + let mut mgr = load_plugin(&shared, Some(policy), "net-scheme").await; + let r = invoke(&mut mgr, "https://example.com/", "GET").await; + print_case("ALLOW expected", "GET", "https://example.com/ (https)", &r); + let r = invoke(&mut mgr, "http://example.com/", "GET").await; + print_case("DENY expected", "GET", "http://example.com/ (http)", &r); + println!(); + + // ========================================================================= + // Scenario 6: Method enforcement + // Only GET is allowed. POST must be denied. + // ========================================================================= + println!("{}--- Scenario 6: method enforcement allowed_methods=[GET]{}", CYAN, RESET); + let policy = SandboxPolicy { + allowed_network: vec![NetworkRule { + host: "example.com".to_string(), + methods: vec!["GET".to_string()], + ..Default::default() + }], + ..Default::default() + }; + let mut mgr = load_plugin(&shared, Some(policy), "net-method").await; + let r = invoke(&mut mgr, "https://example.com/", "GET").await; + print_case("ALLOW expected", "GET", "https://example.com/", &r); + let r = invoke(&mut mgr, "https://example.com/", "POST").await; + print_case("DENY expected", "POST", "https://example.com/", &r); + println!(); + + // ========================================================================= + // Scenario 7: Multi-rule — two hosts with different constraints + // api.example.com: GET only on port 443 + // data.example.com: GET+POST on ports 443 and 8443 + // other.com: not listed — denied + // ========================================================================= + println!("{}--- Scenario 7: multi-rule [api.example.com GET:443] [data.example.com GET+POST:443,8443]{}", CYAN, RESET); + let policy = SandboxPolicy { + allowed_network: vec![ + NetworkRule { + host: "api.example.com".to_string(), + ports: vec![443], + methods: vec!["GET".to_string()], + ..Default::default() + }, + NetworkRule { + host: "data.example.com".to_string(), + ports: vec![443, 8443], + methods: vec!["GET".to_string(), "POST".to_string()], + ..Default::default() + }, + ], + ..Default::default() + }; + let mut mgr = load_plugin(&shared, Some(policy), "net-multi").await; + let r = invoke(&mut mgr, "https://api.example.com/", "GET").await; + print_case("ALLOW expected", "GET", "https://api.example.com/", &r); + let r = invoke(&mut mgr, "https://api.example.com/", "POST").await; + print_case("DENY expected", "POST", "https://api.example.com/", &r); + let r = invoke(&mut mgr, "https://data.example.com/", "POST").await; + print_case("ALLOW expected", "POST", "https://data.example.com/", &r); + let r = invoke(&mut mgr, "https://data.example.com:8443/", "GET").await; + print_case("ALLOW expected", "GET", "https://data.example.com:8443/", &r); + let r = invoke(&mut mgr, "https://other.com/", "GET").await; + print_case("DENY expected", "GET", "https://other.com/", &r); + println!(); + + println!("{}=== Demo complete ==={}\n", BOLD, RESET); +} 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..60306a8b --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_plugin_demo.rs @@ -0,0 +1,328 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_plugin_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WASM Plugin Demo +// +// End-to-end demo mirroring cpex-core/examples/plugin_demo.rs, but all 4 +// plugins run as sandboxed WASM binaries using a custom (user-defined) +// payload type. +// +// Demonstrates: +// 1. Define a custom payload + hook types (same as native plugin_demo) +// 2. Register the payload with PayloadSerializerRegistry +// 3. Load 4 WASM plugins via WasmPluginFactory (one per .wasm binary) +// 4. Multi-plugin pipeline with priority ordering and different modes +// 5. Policy groups with tag-based activation (pii, external_authz) +// 6. Context table threading across pre/post-invoke hooks +// 7. Fire-and-forget audit logging through WASM sandbox +// +// Prerequisites: +// cd crates/cpex-wasm-host && make build-all-plugins +// +// Run: +// cargo run -p cpex-wasm-host --example wasm_plugin_demo + +use std::path::PathBuf; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use cpex_core::config::parse_config; +use cpex_core::executor::PipelineResult; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::meta::MetaExtension; +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +// --------------------------------------------------------------------------- +// Step 1: Define a custom payload and hook types +// +// This is the user-defined payload — it can be anything serializable. +// The same struct exists on each guest plugin for JSON serde roundtrip. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +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"; +} + +// --------------------------------------------------------------------------- +// Step 2: Build extensions with MetaExtension for route resolution +// --------------------------------------------------------------------------- + +fn make_tool_extensions(tool_name: &str, tags: &[&str]) -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(tool_name.into()), + tags: tags.iter().map(|s| s.to_string()).collect(), + ..Default::default() + })), + ..Default::default() + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CYAN_BOLD: &str = "\x1b[1;36m"; +const WHITE: &str = "\x1b[97m"; +const RED: &str = "\x1b[31m"; +const RESET: &str = "\x1b[0m"; + +macro_rules! scenario { + ($($arg:tt)*) => { + println!("{}{}{}", CYAN_BOLD, format!($($arg)*), RESET) + }; +} + +fn print_result(_label: &str, result: &PipelineResult) { + if result.continue_processing { + println!(" {}Result: ALLOWED{}", WHITE, RESET); + } else { + let violation = result.violation.as_ref().unwrap(); + println!( + " {}Result: DENIED by '{}' — {} [{}]{}", + RED, + violation.plugin_name.as_deref().unwrap_or("unknown"), + violation.reason, + violation.code, + RESET, + ); + } +} + +// --------------------------------------------------------------------------- +// Step 3: Main — load config, register WASM factories, invoke hooks +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + // Initialize tracing so plugin cpex_log! calls are visible + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("info".parse().unwrap()), + ) + .init(); + + println!("=== WASM Plugin Demo ===\n"); + + // --- Load config from YAML --- + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_plugin_demo.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + println!("--- Loading config from {} ---\n", "config/config_plugin_demo.yaml"); + 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(); + + // --- Build payload registry with our custom type --- + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + // --- Create manager and register a WASM factory per plugin kind --- + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://tool-invoke-checker.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone()).expect("engine")), + ); + mgr.register_factory( + "wasm://pii-guard.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone()).expect("engine")), + ); + mgr.register_factory( + "wasm://remote-authz.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone()).expect("engine")), + ); + mgr.register_factory( + "wasm://audit-logger-custom.wasm", + Box::new(WasmPluginFactory::new(wasm_dir, registry).expect("engine")), + ); + + 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 + // Expected: identity-resolver allows → pii-guard DENIES + // ========================================================================= + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + scenario!("\n=== 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); + bg.wait_for_background_tasks().await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // ========================================================================= + // Scenario 2: PII tool with clearance + // Expected: identity-resolver allows → pii-guard allows → audit-logger logs + // ========================================================================= + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + scenario!("\n=== 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", &[]); + let mut ctx_table = cpex_core::context::PluginContextTable::new(); + ctx_table + .global_state + .insert("pii_clearance".into(), serde_json::Value::Bool(true)); + let (result, bg) = mgr + .invoke::(payload, ext, Some(ctx_table)) + .await; + print_result("get_compensation (with clearance)", &result); + bg.wait_for_background_tasks().await; + + // Thread context table into post-invoke + println!("\n --- 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; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // ========================================================================= + // Scenario 3: Non-PII tool + // Expected: identity-resolver allows → audit-logger logs → ALLOWED + // ========================================================================= + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + scenario!("\n=== 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; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // ========================================================================= + // Scenario 4: Unknown tool (wildcard route) + // Expected: identity-resolver allows → audit-logger logs → ALLOWED + // ========================================================================= + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + scenario!("\n=== 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; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // ========================================================================= + // Scenario 5: Remote authz — cache hit (alice is in ACL) + // Expected: identity-resolver allows → remote-authz allows → ALLOWED + // ========================================================================= + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + scenario!("\n=== Scenario 5: query_external_data (remote authz, ACL 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; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // ========================================================================= + // Scenario 6: Remote authz — cache miss (charlie is NOT in ACL) + // Expected: identity-resolver allows → remote-authz DENIES + // ========================================================================= + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + scenario!("\n=== Scenario 6: query_external_data (remote authz, ACL 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; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // ========================================================================= + // Scenario 7: No user identity + // Expected: identity-resolver DENIES (first in pipeline) + // ========================================================================= + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + scenario!("\n=== Scenario 7: list_departments (no user identity) ===\n"); + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "".into(), + arguments: "".into(), + }; + let ext = make_tool_extensions("list_departments", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("list_departments (no user)", &result); + bg.wait_for_background_tasks().await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // --- Shutdown --- + println!("--- Shutting down ---\n"); + mgr.shutdown().await; + + println!("=== Demo complete ==="); +} diff --git a/crates/cpex-wasm-host/examples/wasm_resource_limits_demo.rs b/crates/cpex-wasm-host/examples/wasm_resource_limits_demo.rs new file mode 100644 index 00000000..e9976338 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_resource_limits_demo.rs @@ -0,0 +1,223 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_resource_limits_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Resource Limits Sandbox Demo +// +// Demonstrates how fuel, epoch timeout, and memory limits protect the host +// from runaway WASM plugins. Each scenario loads resource-test.wasm with a +// deliberately tiny limit and invokes it in a mode that triggers that limit. +// +// Scenarios: +// 1. Fuel exhaustion — tight loop burns through a 10,000 fuel budget +// 2. Epoch timeout — infinite loop interrupted by 200ms deadline +// 3. Memory limit — 1 MB chunks allocated until 5 MB cap traps +// +// Prerequisites: +// cd crates/cpex-wasm-host && make build-test-plugins +// +// Run: +// cargo run -p cpex-wasm-host --example wasm_resource_limits_demo + +use std::collections::HashMap; +use std::path::PathBuf; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::extensions::container::Extensions; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::policy_loader::{ResourceLimits, SandboxPolicy}; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +// --------------------------------------------------------------------------- +// Terminal colours +// --------------------------------------------------------------------------- + +const BOLD: &str = "\x1b[1m"; +const CYAN: &str = "\x1b[1;36m"; +const GREEN: &str = "\x1b[32m"; +const RED: &str = "\x1b[31m"; +const DIM: &str = "\x1b[2m"; +const RESET: &str = "\x1b[0m"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/resource-test.wasm") +} + +fn make_payload(mode: &str) -> MessagePayload { + let mut arguments = HashMap::new(); + arguments.insert("mode".to_string(), serde_json::json!(mode)); + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: format!("tc_resource_{}", mode), + name: "resource_test".into(), + arguments, + namespace: None, + }, + }], + channel: None, + }, + } +} + +async fn invoke(mgr: &mut SandboxManager, mode: &str) -> Result<(), String> { + let payload = make_payload(mode); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + mgr.invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .map(|_| ()) + .map_err(|e| { + let mut messages = vec![format!("{}", e)]; + let mut source: Option<&dyn std::error::Error> = e.source(); + while let Some(s) = source { + messages.push(format!("{}", s)); + source = s.source(); + } + messages.join(" → ") + }) +} + +async fn load_plugin(shared: &SharedEngine, resources: ResourceLimits, name: &str) -> SandboxManager { + let path = wasm_path(); + let policy = SandboxPolicy { + resources, + ..Default::default() + }; + let mut mgr = SandboxManager::with_shared_engine(shared); + mgr.load_wasmplugin(&path, Some(&policy), name) + .await + .unwrap_or_else(|e| panic!("failed to load plugin '{}': {}", name, e)); + mgr +} + +fn print_result(mode: &str, limit_desc: &str, result: &Result<(), String>, elapsed: std::time::Duration) { + match result { + Ok(()) => { + println!( + " {}[UNEXPECTED]{} mode={:14} limit={}\n {}→ Plugin completed (limit did NOT fire){}\n", + GREEN, RESET, mode, limit_desc, GREEN, RESET, + ); + } + Err(err) => { + println!( + " {}[TRAPPED]{} mode={:14} limit={}\n {}→ Plugin trapped in {:?}{}\n {}Error: {}{}", + RED, RESET, mode, limit_desc, RED, elapsed, RESET, DIM, err, RESET, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("warn".parse().unwrap()), + ) + .init(); + + let path = wasm_path(); + if !path.exists() { + eprintln!( + "{}ERROR:{} resource-test.wasm not found at {}\n\ + Run: cd crates/cpex-wasm-host && make build-test-plugins", + RED, RESET, + path.display() + ); + std::process::exit(1); + } + + println!("{}=== Resource Limits Sandbox Demo ==={}\n", BOLD, RESET); + println!("{}Plugin:{} resource-test.wasm", DIM, RESET); + println!("{}Payload:{} mode passed as ToolCall argument", DIM, RESET); + println!( + "{}Goal:{} each scenario triggers a resource limit trap, proving the host\n\ + {} {}cleanly terminates runaway plugins without affecting itself.\n", + DIM, RESET, DIM, RESET + ); + + let shared = SharedEngine::new().unwrap(); + + // ========================================================================= + // Scenario 1: Fuel exhaustion + // 10,000 fuel units — enough to instantiate but the tight loop exhausts + // it almost immediately. + // ========================================================================= + println!("{}Scenario 1: fuel exhaustion (max_fuel=10,000){}", CYAN, RESET); + let mut mgr = load_plugin(&shared, ResourceLimits { + max_fuel: Some(10_000), + max_execution_time_ms: Some(10_000), + ..Default::default() + }, "resource-fuel").await; + + let start = std::time::Instant::now(); + let r = invoke(&mut mgr, "burn_fuel").await; + let elapsed = start.elapsed(); + print_result("burn_fuel", "max_fuel=10,000", &r, elapsed); + println!(); + + // ========================================================================= + // Scenario 2: Epoch timeout + // 200ms deadline — the infinite loop is interrupted by the epoch ticker. + // ========================================================================= + println!("{}Scenario 2: epoch timeout (max_execution_time_ms=200){}", CYAN, RESET); + let mut mgr = load_plugin(&shared, ResourceLimits { + max_execution_time_ms: Some(200), + max_fuel: Some(500_000_000), + ..Default::default() + }, "resource-timeout").await; + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + + let start = std::time::Instant::now(); + let r = invoke(&mut mgr, "burn_fuel").await; + let elapsed = start.elapsed(); + print_result("burn_fuel", "max_execution_time_ms=200", &r, elapsed); + println!(); + + // ========================================================================= + // Scenario 3: Memory limit + // 5 MB cap — the plugin allocates 1 MB chunks until memory.grow is denied. + // ========================================================================= + println!("{}Scenario 3: memory limit (max_memory_bytes=5MB){}", CYAN, RESET); + let mut mgr = load_plugin(&shared, ResourceLimits { + max_memory_bytes: Some(5 * 1024 * 1024), + max_fuel: Some(u64::MAX), + max_execution_time_ms: Some(10_000), + ..Default::default() + }, "resource-memory").await; + + let start = std::time::Instant::now(); + let r = invoke(&mut mgr, "alloc_memory").await; + let elapsed = start.elapsed(); + print_result("alloc_memory", "max_memory_bytes=5MB", &r, elapsed); + + println!("\n{}=== Demo complete ==={}\n", BOLD, RESET); + println!( + "{}All three resource limits fired correctly — runaway plugins are\n\ + terminated cleanly without host degradation.{}\n", + DIM, RESET + ); +} diff --git a/crates/cpex-wasm-host/examples/wasm_token_attenuator_demo.rs b/crates/cpex-wasm-host/examples/wasm_token_attenuator_demo.rs new file mode 100644 index 00000000..66a78563 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_token_attenuator_demo.rs @@ -0,0 +1,190 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_token_attenuator_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Token Attenuator WASM Demo +// +// Demonstrates the token.delegate hook using the token-attenuator WASM plugin. +// The host constructs a DelegationPayload (bearer token + target tool), fires +// invoke_named, and the WASM plugin mints a scoped outbound credential for the +// downstream tool. The minted token is extracted via DelegationPayload::from_pipeline_result. +// +// Scenarios: +// 1. Delegate to a Tool target — plugin mints a scoped token +// 2. Delegate to an Agent target — plugin passes through (non-Tool targets are skipped) +// 3. Multi-permission delegation — plugin scopes token to required_permissions +// +// Prerequisites: +// cd crates/cpex-wasm-host && make build-all-plugins +// +// Run: +// cargo run -p cpex-wasm-host --example wasm_token_attenuator_demo + +use std::path::PathBuf; +use std::sync::Arc; + +use cpex_core::config::parse_config; +use cpex_core::delegation::{DelegationPayload, TargetType, TokenDelegateHook, HOOK_TOKEN_DELEGATE}; +use cpex_core::extensions::container::Extensions; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CYAN_BOLD: &str = "\x1b[1;36m"; +const GREEN: &str = "\x1b[32m"; +const YELLOW: &str = "\x1b[33m"; +const WHITE: &str = "\x1b[97m"; +const RESET: &str = "\x1b[0m"; + +macro_rules! scenario { + ($($arg:tt)*) => { + println!("{}{}{}", CYAN_BOLD, format!($($arg)*), RESET) + }; +} + +fn print_delegation_result(result: &cpex_core::executor::PipelineResult) { + match DelegationPayload::from_pipeline_result(result) { + Some(resolved) => { + if let Some(token) = &resolved.delegated_token { + println!( + " {}Token minted:{} audience='{}' scopes={:?} header='{}'", + GREEN, RESET, + token.audience, + token.scopes, + token.outbound_header, + ); + println!( + " {}Expires at:{} {}", + WHITE, RESET, + token.expires_at.format("%Y-%m-%dT%H:%M:%SZ"), + ); + } else { + println!(" {}Passed through — no token minted{}", YELLOW, RESET); + } + if let Some(mode) = &resolved.delegation_mode { + println!(" {}Mode:{} {:?}", WHITE, RESET, mode); + } + if let Some(minter) = resolved.metadata.get("minter") { + println!(" {}Minted by:{} {}", WHITE, RESET, minter); + } + } + None => { + println!(" Pipeline denied the delegation request."); + } + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("info".parse().unwrap()), + ) + .init(); + + println!("=== Token Attenuator WASM Demo ===\n"); + + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_token_attenuator_demo.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + println!( + "--- Loading config from {} ---\n", + "config/config_token_attenuator_demo.yaml" + ); + 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(); + + // DelegationPayload is a built-in type — use with_builtin_payloads so it's + // registered without needing a manual PayloadSerializerRegistry setup. + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://token-attenuator.wasm", + Box::new(WasmPluginFactory::new(wasm_dir, registry).expect("engine")), + ); + + mgr.load_config(cpex_config).unwrap(); + + println!("\n--- Initializing plugins ---\n"); + mgr.initialize().await.unwrap(); + + println!( + "Plugins loaded: {}\n", + mgr.plugin_count() + ); + + // ========================================================================= + // Scenario 1: Delegate to a Tool target + // The plugin mints a scoped token because target_type == Tool. + // ========================================================================= + scenario!("\n=== Scenario 1: delegate to Tool target 'get_compensation' ===\n"); + let payload = DelegationPayload::new("eyJhbGciOiJSUzI1NiJ9.caller-token", "get_compensation") + .with_target_type(TargetType::Tool) + .with_target_audience("https://hr-service.internal/api") + .with_required_permissions(vec!["read:compensation".into()]); + + let (result, bg) = mgr + .invoke_named::(HOOK_TOKEN_DELEGATE, payload, Extensions::default(), None) + .await; + print_delegation_result(&result); + bg.wait_for_background_tasks().await; + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // ========================================================================= + // Scenario 2: Delegate to an Agent target + // The plugin returns allow() without minting — non-Tool targets are skipped. + // ========================================================================= + scenario!("\n=== Scenario 2: delegate to Agent target 'summarizer-agent' ===\n"); + let payload = DelegationPayload::new("eyJhbGciOiJSUzI1NiJ9.caller-token", "summarizer-agent") + .with_target_type(TargetType::Agent) + .with_target_audience("https://agents.internal/summarizer"); + + let (result, bg) = mgr + .invoke_named::(HOOK_TOKEN_DELEGATE, payload, Extensions::default(), None) + .await; + print_delegation_result(&result); + bg.wait_for_background_tasks().await; + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // ========================================================================= + // Scenario 3: Multi-permission delegation + // Required permissions are forwarded as token scopes. + // ========================================================================= + scenario!("\n=== Scenario 3: multi-permission delegation to 'query_external_data' ===\n"); + let payload = DelegationPayload::new("eyJhbGciOiJSUzI1NiJ9.caller-token", "query_external_data") + .with_target_type(TargetType::Tool) + .with_target_audience("https://data-svc.internal/query") + .with_required_permissions(vec![ + "read:records".into(), + "read:metadata".into(), + "read:audit".into(), + ]); + + let (result, bg) = mgr + .invoke_named::(HOOK_TOKEN_DELEGATE, payload, Extensions::default(), None) + .await; + print_delegation_result(&result); + bg.wait_for_background_tasks().await; + + println!("\n=== Demo complete ===\n"); +} diff --git a/crates/cpex-wasm-host/src/conversions.rs b/crates/cpex-wasm-host/src/conversions.rs new file mode 100644 index 00000000..18984664 --- /dev/null +++ b/crates/cpex-wasm-host/src/conversions.rs @@ -0,0 +1,1462 @@ +// Location: ./crates/cpex-wasm-host/src/conversions.rs +// Copyright 2026 +// 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 chrono::DateTime; + +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::delegation::payload::{ + AttenuationConfig as NativeAttenuationConfig, AuthEnforcedBy as NativeAuthEnforcedBy, + DelegationPayload as NativeDelegationPayload, TargetType as NativeTargetType, +}; +use cpex_core::error::PluginViolation as NativePluginViolation; +use cpex_core::executor::ErasedResultFields; +use cpex_core::extensions::agent::AgentExtension as NativeAgentExtension; +use cpex_core::extensions::authorization::AuthorizationDetail as NativeAuthDetail; +use cpex_core::extensions::completion::{ + CompletionExtension as NativeCompletionExtension, StopReason as NativeStopReason, +}; +use cpex_core::extensions::container::{ + Extensions as NativeExtensions, OwnedExtensions as NativeOwnedExtensions, +}; +use cpex_core::extensions::delegation::{ + DelegationExtension as NativeDelegationExtension, DelegationHop as NativeDelegationHop, + DelegationStrategy as NativeDelegationStrategy, +}; +use cpex_core::extensions::framework::FrameworkExtension as NativeFrameworkExtension; +use cpex_core::extensions::http::HttpExtension as NativeHttpExtension; +use cpex_core::extensions::llm::LLMExtension as NativeLLMExtension; +use cpex_core::extensions::mcp::{ + MCPExtension as NativeMCPExtension, PromptMetadata as NativePromptMetadata, + ResourceMetadata as NativeResourceMetadata, ToolMetadata as NativeToolMetadata, +}; +use cpex_core::extensions::meta::MetaExtension as NativeMetaExtension; +use cpex_core::extensions::provenance::ProvenanceExtension as NativeProvenanceExtension; +use cpex_core::extensions::raw_credentials::{ + DelegationMode as NativeDelegationMode, RawDelegatedToken as NativeRawDelegatedToken, +}; +use cpex_core::extensions::request::RequestExtension as NativeRequestExtension; +use cpex_core::extensions::security::{ + ClientExtension as NativeClientExtension, ClientTrustLevel as NativeClientTrustLevel, + DataPolicy as NativeDataPolicy, ObjectSecurityProfile as NativeObjectSecurityProfile, + RetentionPolicy as NativeRetentionPolicy, SecurityExtension as NativeSecurityExtension, + SubjectExtension as NativeSubjectExtension, SubjectType as NativeSubjectType, + WorkloadIdentity as NativeWorkloadIdentity, +}; +use cpex_core::hooks::payload::PluginPayload; +use cpex_core::identity::payload::{ + IdentityPayload as NativeIdentityPayload, TokenSource as NativeTokenSource, +}; + +use crate::payload_registry::PayloadSerializerRegistry; +use crate::sandbox_manager::types::*; + +// --------------------------------------------------------------------------- +// Native → WIT: MessagePayload +// --------------------------------------------------------------------------- + +/// Convert a native CMF MessagePayload to its WIT representation for sending to a WASM plugin. +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: IdentityPayload +// --------------------------------------------------------------------------- + +/// Convert a native IdentityPayload to its WIT representation for sending to a WASM plugin. +pub fn native_identity_payload_to_wit(p: &NativeIdentityPayload) -> IdentityPayload { + let (source, source_custom) = match p.source() { + NativeTokenSource::Bearer => (TokenSource::Bearer, None), + NativeTokenSource::UserToken => (TokenSource::UserToken, None), + NativeTokenSource::Mtls => (TokenSource::Mtls, None), + NativeTokenSource::SpiffeJwtSvid => (TokenSource::SpiffeJwtSvid, None), + NativeTokenSource::ApiKey => (TokenSource::ApiKey, None), + NativeTokenSource::Custom(s) => (TokenSource::Custom, Some(s.clone())), + _ => (TokenSource::Bearer, None), + }; + IdentityPayload { + source, + source_custom, + source_header: p.source_header().map(str::to_owned), + headers: p + .headers() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + client_host: p.client_host().map(str::to_owned), + client_port: p.client_port(), + subject: p.subject.as_ref().map(native_subject_to_wit), + client: p.client.as_ref().map(native_client_to_wit), + caller_workload: p.caller_workload.as_ref().map(native_workload_to_wit), + delegation: p.delegation.as_ref().map(native_delegation_to_wit), + resolved_at: p.resolved_at.map(|dt| dt.to_rfc3339()), + raw_claims: if p.raw_claims.is_empty() { + None + } else { + serde_json::to_string(&p.raw_claims).ok() + }, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: DelegationPayload +// --------------------------------------------------------------------------- + +/// Convert a native DelegationPayload to its WIT representation for sending to a WASM plugin. +pub fn native_delegation_payload_to_wit(p: &NativeDelegationPayload) -> DelegationPayload { + let (target_type, target_type_custom) = match p.target_type() { + NativeTargetType::Tool => (TargetType::Tool, None), + NativeTargetType::Agent => (TargetType::Agent, None), + NativeTargetType::Resource => (TargetType::Resource, None), + NativeTargetType::Service => (TargetType::Service, None), + NativeTargetType::Custom(s) => (TargetType::Custom, Some(s.clone())), + _ => (TargetType::Tool, None), + }; + let auth_enforced_by = match p.auth_enforced_by() { + NativeAuthEnforcedBy::Caller => AuthEnforcedBy::Caller, + NativeAuthEnforcedBy::Target => AuthEnforcedBy::Target, + NativeAuthEnforcedBy::Both => AuthEnforcedBy::Both, + _ => AuthEnforcedBy::Caller, + }; + DelegationPayload { + target_name: p.target_name().to_owned(), + target_type, + target_type_custom, + target_audience: p.target_audience().map(str::to_owned), + required_permissions: p.required_permissions().to_vec(), + trust_domain: p.trust_domain().map(str::to_owned), + auth_enforced_by, + route_attenuation: p.route_attenuation().map(native_attenuation_to_wit), + // token bytes are #[serde(skip)] — omit from WIT + delegated_token: p + .delegated_token + .as_ref() + .map(native_raw_delegated_token_to_wit), + delegation_update: p.delegation_update.as_ref().map(native_delegation_to_wit), + delegation_mode: p.delegation_mode.as_ref().map(|m| match m { + NativeDelegationMode::OnBehalfOfUser => DelegationMode::OnBehalfOfUser, + NativeDelegationMode::AsGateway => DelegationMode::AsGateway, + _ => DelegationMode::OnBehalfOfUser, + }), + minted_at: p.minted_at.map(|dt| dt.to_rfc3339()), + metadata: if p.metadata.is_empty() { + None + } else { + serde_json::to_string(&p.metadata).ok() + }, + } +} + +fn native_attenuation_to_wit(a: &NativeAttenuationConfig) -> AttenuationConfig { + AttenuationConfig { + capabilities: a.capabilities.clone(), + resource_template: a.resource_template.clone(), + actions: a.actions.clone(), + ttl_seconds: a.ttl_seconds, + } +} + +fn native_raw_delegated_token_to_wit(t: &NativeRawDelegatedToken) -> RawDelegatedToken { + RawDelegatedToken { + outbound_header: t.outbound_header.clone(), + audience: t.audience.clone(), + scopes: t.scopes.clone(), + expires_at: t.expires_at.to_rfc3339(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: Extensions +// --------------------------------------------------------------------------- + +/// Convert native Extensions to their WIT representation for sending to a WASM plugin. +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)), + agent: ext.agent.as_ref().map(|a| native_agent_to_wit(a)), + mcp: ext.mcp.as_ref().map(|m| native_mcp_to_wit(m)), + completion: ext.completion.as_ref().map(|c| native_completion_to_wit(c)), + provenance: ext.provenance.as_ref().map(|p| native_provenance_to_wit(p)), + llm: ext.llm.as_ref().map(|l| native_llm_to_wit(l)), + framework: ext.framework.as_ref().map(|f| native_framework_to_wit(f)), + delegation: ext.delegation.as_ref().map(|d| native_delegation_to_wit(d)), + custom: ext + .custom + .as_ref() + .and_then(|c| serde_json::to_string(c.as_ref()).ok()), + } +} + +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), + client: s.client.as_ref().map(native_client_to_wit), + caller_workload: s.caller_workload.as_ref().map(native_workload_to_wit), + this_workload: s.this_workload.as_ref().map(native_workload_to_wit), + auth_method: s.auth_method.clone(), + objects: s + .objects + .iter() + .map(|(k, v)| (k.clone(), native_object_profile_to_wit(v))) + .collect(), + data: s + .data + .iter() + .map(|(k, v)| (k.clone(), native_data_policy_to_wit(v))) + .collect(), + } +} + +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_client_to_wit(c: &NativeClientExtension) -> ClientExtension { + let (trust_level, trust_level_custom) = match &c.trust_level { + NativeClientTrustLevel::FirstParty => (ClientTrustLevel::FirstParty, None), + NativeClientTrustLevel::ThirdParty => (ClientTrustLevel::ThirdParty, None), + NativeClientTrustLevel::Internal => (ClientTrustLevel::Internal, None), + NativeClientTrustLevel::Custom(s) => (ClientTrustLevel::ThirdParty, Some(s.clone())), + _ => (ClientTrustLevel::ThirdParty, None), + }; + ClientExtension { + client_id: c.client_id.clone(), + client_name: c.client_name.clone(), + trust_level, + trust_level_custom, + authorized_scopes: c.authorized_scopes.clone(), + authorized_audiences: c.authorized_audiences.clone(), + roles: c.roles.clone(), + permissions: c.permissions.clone(), + teams: c.teams.clone(), + claims: c + .claims + .iter() + .map(|(k, v)| (k.clone(), serde_json::to_string(v).unwrap_or_default())) + .collect(), + } +} + +fn native_workload_to_wit(w: &NativeWorkloadIdentity) -> WorkloadIdentity { + WorkloadIdentity { + spiffe_id: w.spiffe_id.clone(), + trust_domain: w.trust_domain.clone(), + attested_at: w.attested_at.map(|dt| dt.to_rfc3339()), + attestor: w.attestor.clone(), + selectors: w.selectors.clone(), + client_id: w.client_id.clone(), + } +} + +fn native_object_profile_to_wit(o: &NativeObjectSecurityProfile) -> ObjectSecurityProfile { + ObjectSecurityProfile { + managed_by: o.managed_by.clone(), + permissions: o.permissions.clone(), + trust_domain: o.trust_domain.clone(), + data_scope: o.data_scope.clone(), + } +} + +fn native_data_policy_to_wit(d: &NativeDataPolicy) -> DataPolicy { + DataPolicy { + apply_labels: d.apply_labels.clone(), + allowed_actions: d.allowed_actions.clone(), + denied_actions: d.denied_actions.clone(), + retention: d.retention.as_ref().map(|r| RetentionPolicy { + max_age_seconds: r.max_age_seconds, + policy: r.policy.clone(), + delete_after: r.delete_after.clone(), + }), + } +} + +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(), + method: h.method.clone(), + path: h.path.clone(), + host: h.host.clone(), + scheme: h.scheme.clone(), + } +} + +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(), + } +} + +fn native_agent_to_wit(a: &NativeAgentExtension) -> AgentExtension { + AgentExtension { + input: a.input.clone(), + session_id: a.session_id.clone(), + conversation_id: a.conversation_id.clone(), + turn: a.turn, + agent_id: a.agent_id.clone(), + parent_agent_id: a.parent_agent_id.clone(), + conversation: a.conversation.as_ref().map(|c| ConversationContext { + history: c + .history + .iter() + .map(|v| serde_json::to_string(v).unwrap_or_default()) + .collect(), + summary: c.summary.clone(), + topics: c.topics.clone(), + }), + } +} + +fn native_mcp_to_wit(m: &NativeMCPExtension) -> McpExtension { + McpExtension { + tool: m.tool.as_ref().map(native_tool_metadata_to_wit), + resource_info: m.resource.as_ref().map(native_resource_metadata_to_wit), + prompt: m.prompt.as_ref().map(native_prompt_metadata_to_wit), + } +} + +fn native_tool_metadata_to_wit(t: &NativeToolMetadata) -> ToolMetadata { + ToolMetadata { + name: t.name.clone(), + title: t.title.clone(), + description: t.description.clone(), + input_schema: t + .input_schema + .as_ref() + .and_then(|v| serde_json::to_string(v).ok()), + output_schema: t + .output_schema + .as_ref() + .and_then(|v| serde_json::to_string(v).ok()), + server_id: t.server_id.clone(), + namespace: t.namespace.clone(), + annotations: t + .annotations + .iter() + .map(|(k, v)| (k.clone(), serde_json::to_string(v).unwrap_or_default())) + .collect(), + } +} + +fn native_resource_metadata_to_wit(r: &NativeResourceMetadata) -> ResourceMetadata { + ResourceMetadata { + uri: r.uri.clone(), + name: r.name.clone(), + description: r.description.clone(), + mime_type: r.mime_type.clone(), + server_id: r.server_id.clone(), + annotations: r + .annotations + .iter() + .map(|(k, v)| (k.clone(), serde_json::to_string(v).unwrap_or_default())) + .collect(), + } +} + +fn native_prompt_metadata_to_wit(p: &NativePromptMetadata) -> PromptMetadata { + PromptMetadata { + name: p.name.clone(), + description: p.description.clone(), + arguments: p + .arguments + .as_ref() + .and_then(|v| serde_json::to_string(v).ok()), + server_id: p.server_id.clone(), + annotations: p + .annotations + .iter() + .map(|(k, v)| (k.clone(), serde_json::to_string(v).unwrap_or_default())) + .collect(), + } +} + +fn native_completion_to_wit(c: &NativeCompletionExtension) -> CompletionExtension { + CompletionExtension { + stop_reason: c.stop_reason.map(|r| match r { + NativeStopReason::End => StopReason::End, + NativeStopReason::Return => StopReason::ReturnComplete, + NativeStopReason::Call => StopReason::Call, + NativeStopReason::MaxTokens => StopReason::MaxTokens, + NativeStopReason::StopSequence => StopReason::StopSequence, + }), + tokens: c.tokens.as_ref().map(|t| TokenUsage { + input_tokens: t.input_tokens, + output_tokens: t.output_tokens, + total_tokens: t.total_tokens, + }), + model: c.model.clone(), + raw_format: c.raw_format.clone(), + created_at: c.created_at.clone(), + latency_ms: c.latency_ms, + } +} + +fn native_provenance_to_wit(p: &NativeProvenanceExtension) -> ProvenanceExtension { + ProvenanceExtension { + source: p.source.clone(), + message_id: p.message_id.clone(), + parent_id: p.parent_id.clone(), + } +} + +fn native_llm_to_wit(l: &NativeLLMExtension) -> LlmExtension { + LlmExtension { + model_id: l.model_id.clone(), + provider: l.provider.clone(), + capabilities: l.capabilities.clone(), + } +} + +fn native_framework_to_wit(f: &NativeFrameworkExtension) -> FrameworkExtension { + FrameworkExtension { + framework: f.framework.clone(), + framework_version: f.framework_version.clone(), + node_id: f.node_id.clone(), + graph_id: f.graph_id.clone(), + metadata: if f.metadata.is_empty() { + None + } else { + serde_json::to_string(&f.metadata).ok() + }, + } +} + +fn native_delegation_to_wit(d: &NativeDelegationExtension) -> DelegationExtension { + DelegationExtension { + chain: d.chain.iter().map(native_delegation_hop_to_wit).collect(), + depth: d.depth, + origin_subject_id: d.origin_subject_id.clone(), + actor_subject_id: d.actor_subject_id.clone(), + delegated: d.delegated, + age_seconds: d.age_seconds.to_string(), + } +} + +fn native_delegation_hop_to_wit(hop: &NativeDelegationHop) -> DelegationHop { + let (strategy, strategy_custom) = match &hop.strategy { + None => (None, None), + Some(NativeDelegationStrategy::TokenExchange) => { + (Some(DelegationStrategy::TokenExchange), None) + }, + Some(NativeDelegationStrategy::ClientCredentials) => { + (Some(DelegationStrategy::ClientCredentials), None) + }, + Some(NativeDelegationStrategy::SpiffeSvid) => (Some(DelegationStrategy::SpiffeSvid), None), + Some(NativeDelegationStrategy::Passthrough) => { + (Some(DelegationStrategy::Passthrough), None) + }, + Some(NativeDelegationStrategy::Ucan) => (Some(DelegationStrategy::Ucan), None), + Some(NativeDelegationStrategy::TransactionToken) => { + (Some(DelegationStrategy::TransactionToken), None) + }, + Some(NativeDelegationStrategy::Custom(s)) => (None, Some(s.clone())), + Some(_) => (None, None), + }; + DelegationHop { + subject_id: hop.subject_id.clone(), + subject_type: hop.subject_type.as_ref().map(native_subject_type_to_wit), + audience: hop.audience.clone(), + scopes_granted: hop.scopes_granted.clone(), + authorization_details: hop + .authorization_details + .iter() + .map(native_auth_detail_to_wit) + .collect(), + timestamp: hop.timestamp.to_rfc3339(), + ttl_seconds: hop.ttl_seconds, + strategy, + strategy_custom, + from_cache: hop.from_cache, + } +} + +fn native_auth_detail_to_wit(a: &NativeAuthDetail) -> AuthorizationDetail { + AuthorizationDetail { + detail_type: a.detail_type.clone(), + locations: a.locations.clone(), + actions: a.actions.clone(), + datatypes: a.datatypes.clone(), + identifier: a.identifier.clone(), + privileges: a.privileges.clone(), + extra: if a.extra.is_empty() { + None + } else { + serde_json::to_string(&a.extra).ok() + }, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: PluginContext +// --------------------------------------------------------------------------- + +/// Convert a native PluginContext to its WIT representation for sending to a WASM plugin. +pub fn native_context_to_wit(ctx: &NativePluginContext) -> PluginContext { + PluginContext { + local_state: ctx + .local_state + .iter() + .map(|(k, v)| ContextEntry { + key: k.clone(), + value: serde_json::to_string(v).unwrap_or_default(), + }) + .collect(), + global_state: ctx + .global_state + .iter() + .map(|(k, v)| ContextEntry { + key: k.clone(), + value: serde_json::to_string(v).unwrap_or_default(), + }) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: HookResult +// --------------------------------------------------------------------------- + +/// Converts a WIT HookResult into the executor's type-erased result fields. +/// +/// The modified payload stays type-erased (`Box`) so a +/// generic hook (e.g. identity_resolve carrying `IdentityPayload`) gets its +/// modification back as the concrete type the pipeline expects — the registry +/// reconstructs it from the type discriminator. Result `metadata` has no slot +/// in `ErasedResultFields` and is dropped, same as the native erasure path. +pub fn wit_hook_result_to_native( + result: crate::sandbox_manager::types::HookResult, + registry: &PayloadSerializerRegistry, + original_extensions: &NativeExtensions, +) -> (ErasedResultFields, Option) { + wit_hook_result_to_native_filtered(result, registry, original_extensions, None) +} + +/// Same as `wit_hook_result_to_native` but accepts the filtered extensions +/// that were actually sent to the WASM guest. When provided, mutable slots +/// that were hidden from the guest (filtered to `None`) are preserved from +/// the original rather than being nulled by the guest's empty return. +pub fn wit_hook_result_to_native_filtered( + result: crate::sandbox_manager::types::HookResult, + registry: &PayloadSerializerRegistry, + original_extensions: &NativeExtensions, + filtered_extensions: Option<&NativeExtensions>, +) -> (ErasedResultFields, Option) { + let modified_payload: Option> = + result.modified_payload.and_then(|hp| match hp { + HookPayload::Cmf(mp) => { + Some(Box::new(wit_cmf_payload_to_native(mp)) as Box) + }, + HookPayload::Identity(ip) => { + Some(Box::new(wit_identity_payload_to_native(ip)) as Box) + }, + HookPayload::Delegation(dp) => { + Some(Box::new(wit_delegation_payload_to_native(dp)) as Box) + }, + HookPayload::Custom(gp) => { + match registry.deserialize(&gp.payload_type, &gp.payload_data) { + Ok(boxed) => Some(boxed), + Err(e) => { + tracing::warn!( + "custom payload writeback failed for '{}': {}", + gp.payload_type, + e + ); + None + }, + } + }, + }); + + let fields = ErasedResultFields { + continue_processing: result.continue_processing, + modified_payload, + modified_extensions: result + .modified_extensions + .map(|e| wit_extensions_to_owned(e, original_extensions, filtered_extensions)), + violation: result.violation.map(wit_violation_to_native), + }; + + let modified_ctx = result.modified_context.map(wit_context_to_native); + (fields, modified_ctx) +} + +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: v.plugin_name, + proto_error_code: v.proto_error_code, + } +} + +/// Convert a WIT PluginContext received from a WASM plugin to the native representation. +pub fn wit_context_to_native(ctx: PluginContext) -> NativePluginContext { + NativePluginContext { + local_state: ctx + .local_state + .into_iter() + .map(|e| { + ( + e.key, + serde_json::from_str(&e.value).unwrap_or(serde_json::Value::String(e.value)), + ) + }) + .collect(), + global_state: ctx + .global_state + .into_iter() + .map(|e| { + ( + e.key, + serde_json::from_str(&e.value).unwrap_or(serde_json::Value::String(e.value)), + ) + }) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: MessagePayload (for modified_payload in results) +// --------------------------------------------------------------------------- + +/// Convert a WIT MessagePayload received from a WASM plugin to the native representation. +pub fn wit_cmf_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: native_content::ToolCall { + tool_call_id: tc.tool_call_id, + name: tc.name, + arguments: serde_json::from_str(&tc.arguments).unwrap_or_default(), + namespace: tc.namespace, + }, + }, + ContentPart::ToolResult(tr) => native_content::ContentPart::ToolResult { + content: native_content::ToolResult { + tool_call_id: tr.tool_call_id, + tool_name: tr.tool_name, + content: serde_json::from_str(&tr.content) + .unwrap_or(serde_json::Value::String(tr.content)), + is_error: tr.is_error, + }, + }, + ContentPart::CmfResource(r) => native_content::ContentPart::Resource { + content: 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: serde_json::from_str(&r.annotations).unwrap_or_default(), + version: r.version, + }, + }, + ContentPart::ResourceRef(rr) => native_content::ContentPart::ResourceRef { + content: 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, + }, + }, + ContentPart::PromptRequest(pr) => native_content::ContentPart::PromptRequest { + content: native_content::PromptRequest { + prompt_request_id: pr.prompt_request_id, + name: pr.name, + arguments: serde_json::from_str(&pr.arguments).unwrap_or_default(), + server_id: pr.server_id, + }, + }, + ContentPart::PromptResult(pr) => native_content::ContentPart::PromptResult { + content: native_content::PromptResult { + prompt_request_id: pr.prompt_request_id, + prompt_name: pr.prompt_name, + messages: serde_json::from_str(&pr.messages).unwrap_or_default(), + content: pr.content, + is_error: pr.is_error, + error_message: pr.error_message, + }, + }, + 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, + }, + }, + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: IdentityPayload +// --------------------------------------------------------------------------- + +/// Convert a WIT IdentityPayload received from a WASM plugin to the native representation. +pub fn wit_identity_payload_to_native(p: IdentityPayload) -> NativeIdentityPayload { + let source = match p.source { + TokenSource::Bearer => NativeTokenSource::Bearer, + TokenSource::UserToken => NativeTokenSource::UserToken, + TokenSource::Mtls => NativeTokenSource::Mtls, + TokenSource::SpiffeJwtSvid => NativeTokenSource::SpiffeJwtSvid, + TokenSource::ApiKey => NativeTokenSource::ApiKey, + TokenSource::Custom => NativeTokenSource::Custom(p.source_custom.unwrap_or_default()), + }; + let mut out = NativeIdentityPayload::new("", source); + if let Some(h) = p.source_header { + out = out.with_source_header(h); + } + out = out.with_headers(p.headers.into_iter().collect()); + if let Some(h) = p.client_host { + out = out.with_client_host(h); + } + if let Some(port) = p.client_port { + out = out.with_client_port(port); + } + out.subject = p.subject.map(|s| NativeSubjectExtension { + id: s.id, + subject_type: s.subject_type.map(|st| match st { + SubjectType::User => NativeSubjectType::User, + SubjectType::Agent => NativeSubjectType::Agent, + SubjectType::Service => NativeSubjectType::Service, + SubjectType::System => NativeSubjectType::System, + }), + roles: s.roles.into_iter().collect(), + permissions: s.permissions.into_iter().collect(), + teams: s.teams.into_iter().collect(), + claims: s.claims.into_iter().collect(), + }); + out.client = p.client.map(wit_client_to_native); + out.caller_workload = p.caller_workload.map(wit_workload_to_native); + out.delegation = p.delegation.map(wit_delegation_to_native); + out.resolved_at = p + .resolved_at + .as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + out.raw_claims = p + .raw_claims + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + out +} + +// --------------------------------------------------------------------------- +// WIT → Native: DelegationPayload +// --------------------------------------------------------------------------- + +/// Convert a WIT DelegationPayload received from a WASM plugin to the native representation. +pub fn wit_delegation_payload_to_native(p: DelegationPayload) -> NativeDelegationPayload { + let target_type = match p.target_type { + TargetType::Tool => NativeTargetType::Tool, + TargetType::Agent => NativeTargetType::Agent, + TargetType::Resource => NativeTargetType::Resource, + TargetType::Service => NativeTargetType::Service, + TargetType::Custom => NativeTargetType::Custom(p.target_type_custom.unwrap_or_default()), + }; + let auth_enforced_by = match p.auth_enforced_by { + AuthEnforcedBy::Caller => NativeAuthEnforcedBy::Caller, + AuthEnforcedBy::Target => NativeAuthEnforcedBy::Target, + AuthEnforcedBy::Both => NativeAuthEnforcedBy::Both, + }; + // bearer_token is never sent across the boundary; reconstruct with empty string + let mut out = NativeDelegationPayload::new("", p.target_name) + .with_target_type(target_type) + .with_auth_enforced_by(auth_enforced_by); + if let Some(aud) = p.target_audience { + out = out.with_target_audience(aud); + } + if !p.required_permissions.is_empty() { + out = out.with_required_permissions(p.required_permissions); + } + if let Some(td) = p.trust_domain { + out = out.with_trust_domain(td); + } + if let Some(att) = p.route_attenuation { + out = out.with_route_attenuation(NativeAttenuationConfig { + capabilities: att.capabilities, + resource_template: att.resource_template, + actions: att.actions, + ttl_seconds: att.ttl_seconds, + }); + } + out.delegated_token = p.delegated_token.map(|t| { + let expires_at = DateTime::parse_from_rfc3339(&t.expires_at) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|_| chrono::Utc::now()); + // token bytes are not sent across — reconstructed empty + NativeRawDelegatedToken::new("", t.outbound_header, t.audience, t.scopes, expires_at) + }); + out.delegation_update = p.delegation_update.map(wit_delegation_to_native); + out.delegation_mode = p.delegation_mode.map(|m| match m { + DelegationMode::OnBehalfOfUser => NativeDelegationMode::OnBehalfOfUser, + DelegationMode::AsGateway => NativeDelegationMode::AsGateway, + }); + out.minted_at = p + .minted_at + .as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + out.metadata = p + .metadata + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + out +} + +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, + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: Extensions (writeback from guest) +// --------------------------------------------------------------------------- + +fn wit_extensions_to_owned( + ext: Extensions, + original: &NativeExtensions, + filtered: Option<&NativeExtensions>, +) -> NativeOwnedExtensions { + use cpex_core::extensions::guarded::Guarded; + + // Seed from cow_copy() of the pipeline's extensions so the immutable + // slots keep their original Arc pointers — `validate_immutable` checks + // pointer equality, and slots rebuilt from WIT data would always be + // rejected as tampering. Only the mutable slots (http, security, + // delegation, custom) are overlaid with what the guest returned; + // those are the only slots `merge_owned` consumes. + let mut owned = original.cow_copy(); + + // Only overwrite a mutable slot if the guest was authorized to see it + // (i.e., it was present in the filtered view sent to the guest). + // If the slot was hidden by capability filtering, preserve the original + // value from cow_copy() — otherwise the guest's empty return would + // null out the pipeline's slot. + let guest_saw_security = filtered.is_none_or(|f| f.security.is_some()); + let guest_saw_http = filtered.is_none_or(|f| f.http.is_some()); + let guest_saw_delegation = filtered.is_none_or(|f| f.delegation.is_some()); + + if guest_saw_security { + owned.security = ext.security.map(wit_security_to_native); + } + if guest_saw_http { + owned.http = ext.http.map(|h| { + Guarded::new(NativeHttpExtension { + request_headers: h.request_headers.into_iter().collect(), + response_headers: h.response_headers.into_iter().collect(), + method: h.method, + path: h.path, + host: h.host, + scheme: h.scheme, + }) + }); + } + if guest_saw_delegation { + owned.delegation = ext.delegation.map(wit_delegation_to_native); + } + // Custom is unrestricted — always writable + owned.custom = ext.custom.and_then(|s| serde_json::from_str(&s).ok()); + + owned +} + +fn wit_security_to_native(s: SecurityExtension) -> NativeSecurityExtension { + NativeSecurityExtension { + labels: cpex_core::extensions::monotonic::MonotonicSet::from_set( + s.labels.into_iter().collect(), + ), + classification: s.classification, + subject: s.subject.map(|sub| NativeSubjectExtension { + id: sub.id, + subject_type: sub.subject_type.map(|st| match st { + SubjectType::User => NativeSubjectType::User, + SubjectType::Agent => NativeSubjectType::Agent, + SubjectType::Service => NativeSubjectType::Service, + SubjectType::System => NativeSubjectType::System, + }), + roles: sub.roles.into_iter().collect(), + permissions: sub.permissions.into_iter().collect(), + teams: sub.teams.into_iter().collect(), + claims: sub.claims.into_iter().collect(), + }), + client: s.client.map(wit_client_to_native), + caller_workload: s.caller_workload.map(wit_workload_to_native), + this_workload: s.this_workload.map(wit_workload_to_native), + auth_method: s.auth_method, + objects: s + .objects + .into_iter() + .map(|(k, v)| { + ( + k, + NativeObjectSecurityProfile { + managed_by: v.managed_by, + permissions: v.permissions, + trust_domain: v.trust_domain, + data_scope: v.data_scope, + }, + ) + }) + .collect(), + data: s + .data + .into_iter() + .map(|(k, v)| { + ( + k, + NativeDataPolicy { + apply_labels: v.apply_labels, + allowed_actions: v.allowed_actions, + denied_actions: v.denied_actions, + retention: v.retention.map(|r| NativeRetentionPolicy { + max_age_seconds: r.max_age_seconds, + policy: r.policy, + delete_after: r.delete_after, + }), + }, + ) + }) + .collect(), + } +} + +fn wit_client_to_native(c: ClientExtension) -> NativeClientExtension { + let trust_level = match c.trust_level_custom { + Some(s) => NativeClientTrustLevel::Custom(s), + None => match c.trust_level { + ClientTrustLevel::FirstParty => NativeClientTrustLevel::FirstParty, + ClientTrustLevel::ThirdParty => NativeClientTrustLevel::ThirdParty, + ClientTrustLevel::Internal => NativeClientTrustLevel::Internal, + }, + }; + NativeClientExtension { + client_id: c.client_id, + client_name: c.client_name, + trust_level, + authorized_scopes: c.authorized_scopes, + authorized_audiences: c.authorized_audiences, + roles: c.roles, + permissions: c.permissions, + teams: c.teams, + claims: c + .claims + .into_iter() + .map(|(k, v)| { + ( + k, + serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)), + ) + }) + .collect(), + } +} + +fn wit_workload_to_native(w: WorkloadIdentity) -> NativeWorkloadIdentity { + NativeWorkloadIdentity { + spiffe_id: w.spiffe_id, + trust_domain: w.trust_domain, + attested_at: w + .attested_at + .as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)), + attestor: w.attestor, + selectors: w.selectors, + client_id: w.client_id, + } +} + +fn wit_delegation_to_native(d: DelegationExtension) -> NativeDelegationExtension { + NativeDelegationExtension { + chain: d + .chain + .into_iter() + .map(|hop| { + let strategy = match (hop.strategy, hop.strategy_custom) { + (Some(DelegationStrategy::TokenExchange), _) => { + Some(NativeDelegationStrategy::TokenExchange) + }, + (Some(DelegationStrategy::ClientCredentials), _) => { + Some(NativeDelegationStrategy::ClientCredentials) + }, + (Some(DelegationStrategy::SpiffeSvid), _) => { + Some(NativeDelegationStrategy::SpiffeSvid) + }, + (Some(DelegationStrategy::Passthrough), _) => { + Some(NativeDelegationStrategy::Passthrough) + }, + (Some(DelegationStrategy::Ucan), _) => Some(NativeDelegationStrategy::Ucan), + (Some(DelegationStrategy::TransactionToken), _) => { + Some(NativeDelegationStrategy::TransactionToken) + }, + (None, Some(s)) => Some(NativeDelegationStrategy::Custom(s)), + (None, None) => None, + }; + NativeDelegationHop { + subject_id: hop.subject_id, + subject_type: hop.subject_type.map(|st| match st { + SubjectType::User => NativeSubjectType::User, + SubjectType::Agent => NativeSubjectType::Agent, + SubjectType::Service => NativeSubjectType::Service, + SubjectType::System => NativeSubjectType::System, + }), + audience: hop.audience, + scopes_granted: hop.scopes_granted, + authorization_details: hop + .authorization_details + .into_iter() + .map(|a| NativeAuthDetail { + detail_type: a.detail_type, + locations: a.locations, + actions: a.actions, + datatypes: a.datatypes, + identifier: a.identifier, + privileges: a.privileges, + extra: a + .extra + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(), + }) + .collect(), + timestamp: DateTime::parse_from_rfc3339(&hop.timestamp) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|_| chrono::Utc::now()), + ttl_seconds: hop.ttl_seconds, + strategy, + from_cache: hop.from_cache, + } + }) + .collect(), + depth: d.depth, + origin_subject_id: d.origin_subject_id, + actor_subject_id: d.actor_subject_id, + delegated: d.delegated, + age_seconds: d.age_seconds.parse().unwrap_or(0.0), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use cpex_core::hooks::payload::WasmSerializablePayload; + use cpex_core::identity::{IdentityPayload, TokenSource}; + + use super::*; + + fn empty_hook_result() -> HookResult { + HookResult { + continue_processing: true, + modified_payload: None, + modified_extensions: None, + modified_context: None, + violation: None, + metadata: None, + } + } + + #[test] + fn test_generic_modified_payload_writeback_preserves_concrete_type() { + // A guest returning a modified IdentityPayload must come back as + // IdentityPayload, not be silently dropped for not being CMF. + let mut registry = PayloadSerializerRegistry::new(); + registry.register::(); + + let mut headers = HashMap::new(); + headers.insert("x-user-id".to_string(), "alice".to_string()); + let mut payload = + IdentityPayload::new("secret-token", TokenSource::Bearer).with_headers(headers); + payload.subject = Some(NativeSubjectExtension { + id: Some("alice".to_string()), + ..Default::default() + }); + + let result = HookResult { + modified_payload: Some(HookPayload::Custom(CustomPayload { + payload_type: "cpex.identity".to_string(), + payload_data: payload.to_wasm_bytes().unwrap(), + })), + ..empty_hook_result() + }; + + let (fields, _) = + wit_hook_result_to_native(result, ®istry, &NativeExtensions::default()); + + let boxed = fields.modified_payload.expect("modified payload dropped"); + let roundtripped = boxed + .as_any() + .downcast_ref::() + .expect("payload lost its concrete type across the boundary"); + assert_eq!( + roundtripped.subject.as_ref().and_then(|s| s.id.as_deref()), + Some("alice") + ); + // The raw token is #[serde(skip)] — it must NOT survive transport. + assert_eq!(roundtripped.raw_token(), ""); + } + + #[test] + fn test_cmf_modified_payload_writeback() { + let registry = PayloadSerializerRegistry::new(); + let native = native_msg::MessagePayload { + message: native_msg::Message::text(native_enums::Role::User, "hello"), + }; + + let result = HookResult { + modified_payload: Some(HookPayload::Cmf(native_payload_to_wit(&native))), + ..empty_hook_result() + }; + + let (fields, _) = + wit_hook_result_to_native(result, ®istry, &NativeExtensions::default()); + let boxed = fields.modified_payload.expect("modified payload dropped"); + assert!(boxed + .as_any() + .downcast_ref::() + .is_some()); + } + + #[test] + fn test_modified_extensions_pass_validate_immutable() { + // Guest-returned extensions are rebuilt from WIT data. The owned + // copy must share the original's Arcs on immutable slots or the + // executor rejects the whole modification as tampering. + let mut security = NativeSecurityExtension::default(); + security.add_label("PII"); + + let original = NativeExtensions { + request: Some(Arc::new(NativeRequestExtension { + request_id: Some("req-1".to_string()), + ..Default::default() + })), + meta: Some(Arc::new(NativeMetaExtension { + entity_type: Some("tool".to_string()), + ..Default::default() + })), + security: Some(Arc::new(security)), + ..Default::default() + }; + + // Simulate the guest echoing extensions back (with a label added). + let mut wit_ext = native_extensions_to_wit(&original); + if let Some(ref mut s) = wit_ext.security { + s.labels.push("CHECKED".to_string()); + } + + let result = HookResult { + modified_extensions: Some(wit_ext), + ..empty_hook_result() + }; + + let registry = PayloadSerializerRegistry::new(); + let (fields, _) = wit_hook_result_to_native(result, ®istry, &original); + let owned = fields.modified_extensions.expect("extensions dropped"); + + assert!( + original.validate_immutable(&owned), + "writeback extensions flagged as tampering" + ); + + // Monotonic label check must also pass: original labels preserved. + let new_sec = owned.security.as_ref().unwrap(); + let orig_sec = original.security.as_ref().unwrap(); + assert!(new_sec.labels.is_superset(&orig_sec.labels)); + assert!(new_sec.has_label("CHECKED")); + } +} diff --git a/crates/cpex-wasm-host/src/factory.rs b/crates/cpex-wasm-host/src/factory.rs new file mode 100644 index 00000000..0a42b9b6 --- /dev/null +++ b/crates/cpex-wasm-host/src/factory.rs @@ -0,0 +1,564 @@ +// Location: ./crates/cpex-wasm-host/src/factory.rs +// Copyright 2026 +// 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::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; +use tracing::warn; + +use cpex_core::cmf::message::MessagePayload; +use cpex_core::context::PluginContext; +use cpex_core::delegation::DelegationPayload; +use cpex_core::error::PluginError; +use cpex_core::extensions::{Extensions, OwnedExtensions}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::payload::PluginPayload; +use cpex_core::identity::IdentityPayload; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use crate::conversions::{ + native_context_to_wit, native_delegation_payload_to_wit, native_extensions_to_wit, + native_identity_payload_to_wit, native_payload_to_wit, wit_hook_result_to_native_filtered, +}; +use crate::payload_registry::PayloadSerializerRegistry; +use crate::sandbox_manager::SandboxManager; + +// --------------------------------------------------------------------------- +// WasmPluginFactory +// --------------------------------------------------------------------------- + +/// Factory that creates WASM plugin instances using a shared wasmtime engine. +/// All plugins from the same factory share one engine and one epoch ticker thread. +/// Each plugin gets its own Store (isolated memory, fuel, state) — no contention. +pub struct WasmPluginFactory { + wasm_dir: PathBuf, + registry: Arc, + shared_engine: Arc, +} + +impl WasmPluginFactory { + /// Create a factory with a pre-built payload registry and shared engine. + pub fn new( + wasm_dir: PathBuf, + registry: Arc, + ) -> Result { + let shared_engine = Arc::new(crate::sandbox_manager::SharedEngine::new()?); + Ok(Self { + wasm_dir, + registry, + shared_engine, + }) + } + + /// Convenience constructor that pre-registers all built-in payload types: + /// `MessagePayload` (CMF hooks), `IdentityPayload` (identity_resolve), + /// and `DelegationPayload` (token_delegate). + /// Credential fields marked `#[serde(skip)]` on the identity and + /// delegation payloads never cross the sandbox boundary. + /// + /// # Panics + /// Panics if the wasmtime engine cannot be created (e.g., system thread limit reached). + /// Use `WasmPluginFactory::new()` if you need to handle this as a `Result`. + pub fn with_builtin_payloads(wasm_dir: PathBuf) -> Self { + let mut registry = PayloadSerializerRegistry::new(); + registry.register::(); + registry.register::(); + registry.register::(); + Self::new(wasm_dir, Arc::new(registry)) + .expect("failed to create shared wasmtime engine") + } +} + +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 SandboxManager backed by the shared engine (one epoch thread for all plugins) + let sandbox = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let mut mgr = SandboxManager::with_shared_engine(&self.shared_engine); + mgr.load_wasmplugin(&wasm_path, sandbox_policy.as_ref(), &config.name) + .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 timeout_ms = sandbox_policy + .as_ref() + .and_then(|p| p.resources.max_execution_time_ms) + .unwrap_or(5_000); + + let plugin: Arc = Arc::new(WasmBridgePlugin { + config: config.clone(), + }); + + // Register a separate handler per hook so each carries its own hook_name. + // `Box::leak` converts each hook name string into a `&'static str` required + // by the `PluginInstance::handlers` type. Hook names are short, bounded by + // the plugin config, and registered once at startup — the leak is intentional. + let hooks: Vec<(&'static str, Arc)> = config + .hooks + .iter() + .map(|hook_name| { + let leaked: &'static str = Box::leak(hook_name.clone().into_boxed_str()); + let handler: Arc = Arc::new(WasmBridgeHandler { + plugin_name: config.name.clone(), + hook_name: hook_name.clone(), + sandbox: sandbox.clone(), + registry: self.registry.clone(), + capabilities: config.capabilities.clone(), + timeout_ms, + }); + (leaked, handler) + }) + .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, + hook_name: String, + sandbox: Arc>, + registry: Arc, + capabilities: HashSet, + timeout_ms: u64, +} + +#[async_trait] +impl AnyHookHandler for WasmBridgeHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + // Build the WIT payload: structured fast-path for known types, generic fallback via registry + let wit_hook_payload = if let Some(cmf) = payload.as_any().downcast_ref::() + { + crate::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(cmf)) + } else if let Some(identity) = payload.as_any().downcast_ref::() { + crate::sandbox_manager::types::HookPayload::Identity(native_identity_payload_to_wit( + identity, + )) + } else if let Some(delegation) = payload.as_any().downcast_ref::() { + crate::sandbox_manager::types::HookPayload::Delegation( + native_delegation_payload_to_wit(delegation), + ) + } else if self.registry.contains_type_id(payload.as_any().type_id()) { + let (type_name, bytes) = self.registry.serialize(payload).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}': payload serialization failed: {}", + self.plugin_name, e + ), + }) + })?; + crate::sandbox_manager::types::HookPayload::Custom( + crate::sandbox_manager::types::CustomPayload { + payload_type: type_name.to_string(), + payload_data: bytes, + }, + ) + } else { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': payload type not registered in PayloadSerializerRegistry", + self.plugin_name, + ), + })); + }; + + // The executor already filters extensions by capabilities before calling + // this handler. We convert directly — no redundant re-filtering. + 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(&self.hook_name, wit_hook_payload, wit_extensions, wit_ctx) + .await + .map_err(|e| classify_wasm_error(&self.plugin_name, self.timeout_ms, e))? + }; + + // Convert WIT HookResult → type-erased result fields + optional + // context writeback. Pass `extensions` as the filtered reference so + // hidden slots are preserved during writeback. + let (mut erased_fields, modified_ctx) = wit_hook_result_to_native_filtered( + wit_result, + &self.registry, + extensions, + Some(extensions), + ); + + // Defense-in-depth: validate extension modifications returned by WASM + if let Some(ref owned) = erased_fields.modified_extensions { + if !validate_extension_modifications( + owned, + extensions, + &self.capabilities, + &self.plugin_name, + &self.hook_name, + ) { + erased_fields.modified_extensions = None; + } + } + + if let Some(new_ctx) = modified_ctx { + ctx.local_state = new_ctx.local_state; + ctx.global_state = new_ctx.global_state; + } + + Ok(Box::new(erased_fields)) + } + + fn hook_type_name(&self) -> &'static str { + // Returns the hook name this handler is registered under (e.g. "cmf.tool_pre_invoke"). + // The PluginManager uses this during override-instance dispatch to locate the + // matching handler in a freshly created instance by name comparison. + // Each WasmBridgeHandler is created with hook_name already leaked to 'static + // in WasmPluginFactory::create(), so we can return it directly here. + Box::leak(self.hook_name.clone().into_boxed_str()) + } +} + +// --------------------------------------------------------------------------- +// Post-invocation validation — defense-in-depth at the WASM trust boundary +// --------------------------------------------------------------------------- + +/// Validate extension modifications returned by a WASM plugin. +/// +/// Checks immutable tier integrity, monotonic label enforcement, and +/// write authorization. Returns `true` if the modifications should be +/// accepted, `false` if they must be rejected. +fn validate_extension_modifications( + owned: &OwnedExtensions, + original: &Extensions, + capabilities: &HashSet, + plugin_name: &str, + hook_name: &str, +) -> bool { + // Check 1: Immutable tier — Arc pointers must be identical + if !original.validate_immutable(owned) { + warn!( + "[WASM] plugin '{}' hook '{}': violated immutable tier — \ + modified an immutable extension slot. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + + // Check 2: Monotonic labels — can only add, never remove + if capabilities.contains("read_labels") { + if let (Some(ref orig_sec), Some(ref new_sec)) = (&original.security, &owned.security) { + if !new_sec.labels.is_superset(&orig_sec.labels) { + warn!( + "[WASM] plugin '{}' hook '{}': violated monotonic tier — \ + removed a security label. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + } + } + + // Check 3: Write authorization — reject mutations on slots without write cap + if !capabilities.contains("write_headers") { + if let Some(ref http_guarded) = owned.http { + let new_http = http_guarded.read(); + let http_changed = match original.http.as_ref() { + Some(orig) => { + new_http.request_headers != orig.request_headers + || new_http.response_headers != orig.response_headers + }, + None => { + !new_http.request_headers.is_empty() || !new_http.response_headers.is_empty() + }, + }; + if http_changed { + warn!( + "[WASM] plugin '{}' hook '{}': modified HTTP headers \ + without write_headers capability. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + } + } + + if !capabilities.contains("append_labels") { + if let Some(ref new_sec) = owned.security { + let labels_changed = match original.security.as_ref() { + Some(orig) => new_sec.labels.len() != orig.labels.len(), + None => !new_sec.labels.is_empty(), + }; + if labels_changed { + warn!( + "[WASM] plugin '{}' hook '{}': modified security labels \ + without append_labels capability. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + } + } + + if !capabilities.contains("append_delegation") { + if let Some(ref new_deleg) = owned.delegation { + let delegation_changed = match original.delegation.as_ref() { + Some(orig) => { + new_deleg.chain.len() != orig.chain.len() + || new_deleg.depth != orig.depth + || new_deleg.delegated != orig.delegated + }, + None => new_deleg.delegated || !new_deleg.chain.is_empty(), + }; + if delegation_changed { + warn!( + "[WASM] plugin '{}' hook '{}': modified delegation \ + without append_delegation capability. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + } + } + + true +} + +// --------------------------------------------------------------------------- +// WASM error classification — maps wasmtime errors to PluginError variants +// --------------------------------------------------------------------------- + +/// Classify a wasmtime invocation error into the appropriate `PluginError` +/// variant based on the error message content. +/// +/// The executor uses error variants to apply different `OnError` policies +/// and produce accurate diagnostic records. Using the correct variant +/// ensures circuit breakers, timeout logging, and error aggregation all +/// work as designed. +fn classify_wasm_error(plugin_name: &str, timeout_ms: u64, err: anyhow::Error) -> Box { + let msg = err.to_string(); + let msg_lower = msg.to_lowercase(); + + if msg_lower.contains("epoch deadline") { + Box::new(PluginError::Timeout { + plugin_name: plugin_name.to_string(), + timeout_ms, + proto_error_code: None, + }) + } else if msg_lower.contains("all fuel consumed") || msg_lower.contains("fuel") { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM fuel exhausted: {}", msg), + source: None, + code: Some("fuel_exhausted".into()), + details: HashMap::new(), + proto_error_code: None, + }) + } else if msg_lower.contains("memory") + && (msg_lower.contains("grow") || msg_lower.contains("limit")) + { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM memory limit exceeded: {}", msg), + source: None, + code: Some("memory_limit".into()), + details: HashMap::new(), + proto_error_code: None, + }) + } else if msg_lower.contains("unreachable") + || msg_lower.contains("wasm trap") + || msg_lower.contains("panic") + { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM trap: {}", msg), + source: None, + code: Some("wasm_trap".into()), + details: HashMap::new(), + proto_error_code: None, + }) + } else if msg_lower.contains("request denied") || msg_lower.contains("http_request_denied") { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM network access denied: {}", msg), + source: None, + code: Some("network_denied".into()), + details: HashMap::new(), + proto_error_code: None, + }) + } else { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM invocation failed: {}", msg), + source: None, + code: None, + details: HashMap::new(), + proto_error_code: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_classify_epoch_deadline_as_timeout() { + let err = anyhow::anyhow!("wasm trap: epoch deadline has elapsed"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Timeout { + ref plugin_name, + timeout_ms, + .. + } => { + assert_eq!(plugin_name, "test-plugin"); + assert_eq!(timeout_ms, 5000); + }, + _ => panic!("expected Timeout, got {:?}", result), + } + } + + #[test] + fn test_classify_fuel_exhaustion() { + let err = anyhow::anyhow!("all fuel consumed by wasm"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { ref code, .. } => { + assert_eq!(code.as_deref(), Some("fuel_exhausted")); + }, + _ => panic!("expected Execution with fuel_exhausted, got {:?}", result), + } + } + + #[test] + fn test_classify_memory_limit() { + let err = anyhow::anyhow!("memory growth failed: memory limit exceeded"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { ref code, .. } => { + assert_eq!(code.as_deref(), Some("memory_limit")); + }, + _ => panic!("expected Execution with memory_limit, got {:?}", result), + } + } + + #[test] + fn test_classify_wasm_trap() { + let err = anyhow::anyhow!("wasm trap: unreachable instruction executed"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { ref code, .. } => { + assert_eq!(code.as_deref(), Some("wasm_trap")); + }, + _ => panic!("expected Execution with wasm_trap, got {:?}", result), + } + } + + #[test] + fn test_classify_network_denied() { + let err = anyhow::anyhow!("outbound HTTP request denied: host not in allowlist"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { ref code, .. } => { + assert_eq!(code.as_deref(), Some("network_denied")); + }, + _ => panic!("expected Execution with network_denied, got {:?}", result), + } + } + + #[test] + fn test_classify_unknown_error_as_execution() { + let err = anyhow::anyhow!("something unexpected happened in the component"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { + ref code, + ref plugin_name, + .. + } => { + assert_eq!(plugin_name, "test-plugin"); + assert!(code.is_none()); + }, + _ => panic!("expected generic Execution, got {:?}", result), + } + } +} diff --git a/crates/cpex-wasm-host/src/lib.rs b/crates/cpex-wasm-host/src/lib.rs new file mode 100644 index 00000000..dc1736cb --- /dev/null +++ b/crates/cpex-wasm-host/src/lib.rs @@ -0,0 +1,48 @@ +// Location: ./crates/cpex-wasm-host/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +//! WASM plugin host runtime for the CPEX framework. +//! +//! Loads WebAssembly Component Model plugins into sandboxed wasmtime environments, +//! enforces resource limits and capability-based access control, and bridges to +//! cpex-core's [`PluginManager`](cpex_core::manager::PluginManager) for seamless +//! integration with native plugins. +//! +//! # Usage +//! +//! ```ignore +//! use cpex_wasm_host::factory::WasmPluginFactory; +//! use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; +//! +//! // For built-in payloads (CMF, Identity, Delegation): +//! let factory = WasmPluginFactory::with_builtin_payloads(wasm_dir); +//! +//! // For custom payloads: +//! let mut registry = PayloadSerializerRegistry::new(); +//! registry.register::(); +//! let factory = WasmPluginFactory::new(wasm_dir, Arc::new(registry)); +//! +//! // Register with PluginManager — WASM plugins participate transparently +//! mgr.register_factory("wasm://my-plugin.wasm", Box::new(factory)); +//! ``` + +/// Native ↔ WIT type conversions for payloads, extensions, and context. +pub mod conversions; + +/// [`WasmPluginFactory`](factory::WasmPluginFactory) — bridges cpex-core's +/// `PluginFactory` trait to the sandbox runtime. Implements `PluginFactory` so +/// WASM plugins can be registered with `PluginManager`. +pub mod factory; + +/// Type-erased serialization registry for custom payload types crossing the +/// WASM boundary via `HookPayload::Custom`. +pub mod payload_registry; + +/// Sandbox policy parsing and WASI context construction from YAML config. +pub mod policy_loader; + +/// Wasmtime sandbox runtime — `SharedEngine`, `SandboxManager`, and the +/// `host-logging` WIT import implementation. +pub mod sandbox_manager; diff --git a/crates/cpex-wasm-host/src/payload_registry.rs b/crates/cpex-wasm-host/src/payload_registry.rs new file mode 100644 index 00000000..8424f65c --- /dev/null +++ b/crates/cpex-wasm-host/src/payload_registry.rs @@ -0,0 +1,127 @@ +// Location: ./crates/cpex-wasm-host/src/payload_registry.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// PayloadSerializerRegistry — maps payload TypeId ↔ (type_name, serialize, deserialize). +// +// The host registers every payload type it wants to route through the WASM +// boundary here. WasmBridgeHandler calls serialize() to build HookPayload::Custom +// before invocation, and deserialize() to reconstruct a Box +// from the guest's returned custom payload. + +use std::any::TypeId; +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; + +use cpex_core::hooks::payload::{PluginPayload, WasmSerializablePayload}; + +// --------------------------------------------------------------------------- +// Internal codec — per-type serialize + deserialize closures +// --------------------------------------------------------------------------- + +type SerializeFn = Arc Result> + Send + Sync>; +type DeserializeFn = Arc Result> + Send + Sync>; + +struct PayloadCodec { + type_name: &'static str, + serialize: SerializeFn, + deserialize: DeserializeFn, +} + +// --------------------------------------------------------------------------- +// PayloadSerializerRegistry +// --------------------------------------------------------------------------- + +/// Registry that maps payload types to their WASM serialization codecs. +/// +/// Register every payload type that WASM plugins should be able to receive +/// or return. The registry is built once (at factory creation time) and then +/// shared read-only across all handler invocations via `Arc`. +/// +/// # Example +/// +/// ```ignore +/// let mut registry = PayloadSerializerRegistry::new(); +/// registry.register::(); +/// registry.register::(); +/// let registry = Arc::new(registry); +/// ``` +#[derive(Default)] +pub struct PayloadSerializerRegistry { + by_type_id: HashMap, + by_type_name: HashMap<&'static str, TypeId>, +} + +impl PayloadSerializerRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Register a payload type. `T` must implement both `PluginPayload` and + /// `WasmSerializablePayload`. Calling `register` twice for the same type + /// is idempotent — the second call overwrites the first. + pub fn register(&mut self) + where + T: WasmSerializablePayload + 'static, + { + let type_id = TypeId::of::(); + let type_name = T::payload_type_name(); + + let codec = PayloadCodec { + type_name, + serialize: Arc::new(|payload| { + let concrete = payload + .as_any() + .downcast_ref::() + .ok_or_else(|| anyhow!("serialize: TypeId matched but downcast failed"))?; + concrete.to_wasm_bytes().map_err(|e| anyhow!(e)) + }), + deserialize: Arc::new(|bytes| { + let value = T::from_wasm_bytes(bytes).map_err(|e| anyhow!(e))?; + Ok(Box::new(value) as Box) + }), + }; + + self.by_type_name.insert(type_name, type_id); + self.by_type_id.insert(type_id, codec); + } + + /// Serialize a type-erased payload to `(type_name, json_bytes)`. + /// + /// Returns an error if the payload's concrete type has not been registered. + pub fn serialize(&self, payload: &dyn PluginPayload) -> Result<(&'static str, Vec)> { + let type_id = payload.as_any().type_id(); + let codec = self + .by_type_id + .get(&type_id) + .ok_or_else(|| anyhow!("payload type not registered in PayloadSerializerRegistry"))?; + let bytes = (codec.serialize)(payload)?; + Ok((codec.type_name, bytes)) + } + + /// Deserialize a payload from `(type_name, json_bytes)` back to a + /// `Box`. + /// + /// Returns an error if `type_name` has not been registered. + pub fn deserialize(&self, type_name: &str, bytes: &[u8]) -> Result> { + let type_id = self.by_type_name.get(type_name).ok_or_else(|| { + anyhow!( + "unknown payload type '{}' in PayloadSerializerRegistry", + type_name + ) + })?; + let codec = self + .by_type_id + .get(type_id) + .ok_or_else(|| anyhow!("codec missing for type '{}'", type_name))?; + (codec.deserialize)(bytes) + } + + /// Returns true if the given `TypeId` has a registered codec. + pub fn contains_type_id(&self, type_id: TypeId) -> bool { + self.by_type_id.contains_key(&type_id) + } +} 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..236705b2 --- /dev/null +++ b/crates/cpex-wasm-host/src/policy_loader.rs @@ -0,0 +1,511 @@ +// Location: ./crates/cpex-wasm-host/src/policy_loader.rs +// Copyright 2026 +// 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; + +/// A single network access rule — grants outbound HTTP access to one host pattern +/// with optional constraints on port, scheme, and HTTP method. +/// +/// | Field | Default | Meaning | +/// |-------|---------|---------| +/// | `host` | (required) | Exact hostname or `*.example.com` wildcard. Exact match only unless the pattern starts with `*.`. | +/// | `ports` | `[]` (any) | If non-empty, only the listed port numbers are allowed. Default port (80/443) is inferred from scheme when the URI has none. | +/// | `schemes` | `["https"]` | Allowed URI schemes. Default is HTTPS-only. Set `[http, https]` to allow both. | +/// | `methods` | `[]` (any) | If non-empty, only the listed HTTP methods are allowed (case-insensitive). | +/// +/// Examples: +/// ```yaml +/// # HTTPS-only on port 443, any method (typical API) +/// - host: "api.example.com" +/// +/// # Explicit constraints +/// - host: "*.internal.svc" +/// ports: [8080, 8443] +/// schemes: [http, https] +/// methods: [GET, POST] +/// ``` +#[derive(Debug, Clone, PartialEq, Deserialize, serde::Serialize)] +pub struct NetworkRule { + /// Hostname to match. Use `*.example.com` to match all subdomains of `example.com`. + /// A plain `example.com` matches that host exactly — subdomains are not implicitly included. + pub host: String, + /// Allowed port numbers. Empty means any port is permitted. + #[serde(default)] + pub ports: Vec, + /// Allowed URI schemes. Defaults to `["https"]` if omitted. + #[serde(default = "default_schemes")] + pub schemes: Vec, + /// Allowed HTTP methods (case-insensitive). Empty means any method is permitted. + #[serde(default)] + pub methods: Vec, +} + +fn default_schemes() -> Vec { + vec!["https".to_string()] +} + +impl Default for NetworkRule { + fn default() -> Self { + Self { + host: String::new(), + ports: Vec::new(), + schemes: default_schemes(), + methods: Vec::new(), + } + } +} + +/// 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, + /// Outbound HTTP rules. Each entry specifies a host pattern and optional + /// constraints on port, scheme, and HTTP method. Empty = no network access. + #[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, Default, 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, +} + +/// 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 controlling what the plugin can do within this path. + /// + /// | Value | DirPerms | FilePerms | Description | + /// |-------|----------|-----------|-------------| + /// | `read-only` | READ | READ | List and read; no modifications | + /// | `full-access` | READ+MUTATE | READ+WRITE | Full access within the preopen | + /// | `drop-box` | MUTATE | WRITE | create_dir/delete only; file I/O denied (open_at requires DirPerms::READ) | + /// | `fixed-mutable` | READ | READ+WRITE | Read files; FilePerms::WRITE has no effect — wasmtime's open_at requires DirPerms::MUTATE for any write regardless | + /// | `list-only` | READ | (empty) | Enumerate filenames only; cannot open file contents | + /// | `private-scratch` | MUTATE | READ+WRITE | Full file I/O within the preopen; list_dir denied (DirPerms::READ absent) | + 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 rules passed to the NetworkPolicy hook for outbound HTTP filtering + pub allowed_hosts: Arc>, +} + +/// Maps a permission string to the corresponding (DirPerms, FilePerms) flags. +/// Only the 6 named scenarios are accepted; unknown values are rejected. +pub fn resolve_permission(permission: &str) -> Result<(DirPerms, FilePerms)> { + match permission { + "read-only" => Ok((DirPerms::READ, FilePerms::READ)), + "full-access" => Ok(( + DirPerms::READ | DirPerms::MUTATE, + FilePerms::READ | FilePerms::WRITE, + )), + "drop-box" => Ok((DirPerms::MUTATE, FilePerms::WRITE)), + "fixed-mutable" => Ok((DirPerms::READ, FilePerms::READ | FilePerms::WRITE)), + "list-only" => Ok((DirPerms::READ, FilePerms::empty())), + "private-scratch" => Ok(( + DirPerms::MUTATE, + FilePerms::READ | FilePerms::WRITE, + )), + other => anyhow::bail!( + "unknown filesystem permission: '{}'. Valid values: \ + read-only, full-access, drop-box, fixed-mutable, list-only, private-scratch", + other + ), + } +} + +/// 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) = resolve_permission(rule.permission.as_str())?; + + 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_policy_test_fixture.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-only" +allowed_network: + - host: "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.len(), 1); + assert_eq!(policy.allowed_network[0].host, "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()); + } + + #[test] + fn test_no_policy_builds_context_with_no_filesystem() { + let ctx = build_wasi_context(None); + assert!(ctx.is_ok(), "no-policy context should build successfully"); + let ctx = ctx.unwrap(); + assert!(ctx.allowed_hosts.is_empty()); + } + + #[test] + fn test_empty_policy_builds_context_with_no_filesystem() { + let policy = SandboxPolicy::default(); + let ctx = build_wasi_context(Some(&policy)); + assert!(ctx.is_ok()); + let ctx = ctx.unwrap(); + assert!(ctx.allowed_hosts.is_empty()); + } + + #[test] + fn test_nonexistent_directory_fails_to_preopen() { + let policy = SandboxPolicy { + allowed_filesystem: vec![FilesystemRule { + dir: Some("/nonexistent_path_that_does_not_exist_xyz".to_string()), + file: None, + permission: "read-only".to_string(), + }], + ..Default::default() + }; + let result = build_wasi_context(Some(&policy)); + assert!( + result.is_err(), + "preopening a non-existent directory should fail" + ); + } + + #[test] + fn test_invalid_permission_rejected() { + let policy = SandboxPolicy { + allowed_filesystem: vec![FilesystemRule { + dir: Some("/tmp".to_string()), + file: None, + permission: "execute".to_string(), + }], + ..Default::default() + }; + let result = build_wasi_context(Some(&policy)); + assert!( + result.is_err(), + "invalid permission 'execute' should be rejected" + ); + } + + #[test] + fn test_network_allowlist_populated_from_policy() { + let policy = SandboxPolicy { + allowed_network: vec![ + NetworkRule { host: "api.internal.svc".to_string(), ..Default::default() }, + NetworkRule { host: "auth.example.com".to_string(), ..Default::default() }, + ], + ..Default::default() + }; + let ctx = build_wasi_context(Some(&policy)).unwrap(); + assert_eq!(ctx.allowed_hosts.len(), 2); + assert!(ctx.allowed_hosts.iter().any(|r| r.host == "api.internal.svc")); + assert!(ctx.allowed_hosts.iter().any(|r| r.host == "auth.example.com")); + } + + #[test] + fn test_network_rule_default_scheme_is_https() { + let rule = NetworkRule { host: "example.com".to_string(), ..Default::default() }; + assert_eq!(rule.schemes, vec!["https"]); + } + + #[test] + fn test_network_rule_empty_ports_means_any_port() { + let rule = NetworkRule { host: "example.com".to_string(), ..Default::default() }; + assert!(rule.ports.is_empty()); + } + + #[test] + fn test_network_rule_empty_methods_means_any_method() { + let rule = NetworkRule { host: "example.com".to_string(), ..Default::default() }; + assert!(rule.methods.is_empty()); + } + + #[test] + fn test_network_rule_wildcard_host_parses() { + let yaml = r#"host: "*.example.com""#; + let rule: NetworkRule = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(rule.host, "*.example.com"); + assert_eq!(rule.schemes, vec!["https"]); + } + + #[test] + fn test_network_rule_full_deserialization() { + let yaml = r#" +host: "*.internal.svc" +ports: [8080, 8443] +schemes: [http, https] +methods: [GET, POST] +"#; + let rule: NetworkRule = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(rule.host, "*.internal.svc"); + assert_eq!(rule.ports, vec![8080, 8443]); + assert_eq!(rule.schemes, vec!["http", "https"]); + assert_eq!(rule.methods, vec!["GET", "POST"]); + } + + #[test] + fn test_network_rule_schemes_override_default() { + let yaml = r#" +host: "api.example.com" +schemes: [http, https] +"#; + let rule: NetworkRule = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(rule.schemes, vec!["http", "https"]); + } + + #[test] + fn test_resolve_permission_read_only() { + let (d, f) = resolve_permission("read-only").unwrap(); + assert_eq!(d, DirPerms::READ); + assert_eq!(f, FilePerms::READ); + } + + #[test] + fn test_resolve_permission_full_access() { + let (d, f) = resolve_permission("full-access").unwrap(); + assert_eq!(d, DirPerms::READ | DirPerms::MUTATE); + assert_eq!(f, FilePerms::READ | FilePerms::WRITE); + } + + #[test] + fn test_resolve_permission_drop_box() { + let (d, f) = resolve_permission("drop-box").unwrap(); + assert_eq!(d, DirPerms::MUTATE); + assert_eq!(f, FilePerms::WRITE); + } + + #[test] + fn test_resolve_permission_fixed_mutable() { + let (d, f) = resolve_permission("fixed-mutable").unwrap(); + assert_eq!(d, DirPerms::READ); + assert_eq!(f, FilePerms::READ | FilePerms::WRITE); + } + + #[test] + fn test_resolve_permission_list_only() { + let (d, f) = resolve_permission("list-only").unwrap(); + assert_eq!(d, DirPerms::READ); + assert_eq!(f, FilePerms::empty()); + } + + #[test] + fn test_resolve_permission_private_scratch() { + let (d, f) = resolve_permission("private-scratch").unwrap(); + assert_eq!(d, DirPerms::MUTATE); + assert_eq!(f, FilePerms::READ | FilePerms::WRITE); + } + + #[test] + fn test_resolve_permission_unknown_rejected() { + assert!(resolve_permission("execute").is_err()); + assert!(resolve_permission("admin").is_err()); + assert!(resolve_permission("read").is_err()); + assert!(resolve_permission("write").is_err()); + assert!(resolve_permission("mutate").is_err()); + assert!(resolve_permission("").is_err()); + } + + #[test] + fn test_resolve_permission_error_lists_valid_values() { + let err = resolve_permission("bogus").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("read-only")); + assert!(msg.contains("full-access")); + assert!(msg.contains("drop-box")); + assert!(msg.contains("fixed-mutable")); + assert!(msg.contains("list-only")); + assert!(msg.contains("private-scratch")); + } + + #[test] + fn test_build_wasi_context_with_drop_box_permission() { + let policy = SandboxPolicy { + allowed_filesystem: vec![FilesystemRule { + dir: Some("/tmp".to_string()), + file: None, + permission: "drop-box".to_string(), + }], + ..Default::default() + }; + assert!(build_wasi_context(Some(&policy)).is_ok()); + } + + #[test] + fn test_build_wasi_context_with_fixed_mutable_permission() { + let policy = SandboxPolicy { + allowed_filesystem: vec![FilesystemRule { + dir: Some("/tmp".to_string()), + file: None, + permission: "fixed-mutable".to_string(), + }], + ..Default::default() + }; + assert!(build_wasi_context(Some(&policy)).is_ok()); + } + + #[test] + fn test_build_wasi_context_with_list_only_permission() { + let policy = SandboxPolicy { + allowed_filesystem: vec![FilesystemRule { + dir: Some("/tmp".to_string()), + file: None, + permission: "list-only".to_string(), + }], + ..Default::default() + }; + assert!(build_wasi_context(Some(&policy)).is_ok()); + } + + #[test] + fn test_build_wasi_context_with_private_scratch_permission() { + let policy = SandboxPolicy { + allowed_filesystem: vec![FilesystemRule { + dir: Some("/tmp".to_string()), + file: None, + permission: "private-scratch".to_string(), + }], + ..Default::default() + }; + assert!(build_wasi_context(Some(&policy)).is_ok()); + } +} 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..bfd63cc2 --- /dev/null +++ b/crates/cpex-wasm-host/src/sandbox_manager.rs @@ -0,0 +1,399 @@ +// Location: ./crates/cpex-wasm-host/src/sandbox_manager.rs +// Copyright 2026 +// 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::p2::bindings::http::types::ErrorCode; +use wasmtime_wasi_http::p2::body::HyperOutgoingBody; +use wasmtime_wasi_http::p2::types::{HostFutureIncomingResponse, OutgoingRequestConfig}; +use wasmtime_wasi_http::p2::{default_send_request, HttpResult, WasiHttpHooks}; +use wasmtime_wasi_http::p2::{WasiHttpCtxView, WasiHttpView}; +use wasmtime_wasi_http::WasiHttpCtx; + +use crate::policy_loader::{build_wasi_context, NetworkRule, ResourceLimits, SandboxPolicy}; + +// The epoch ticker fires every EPOCH_TICK_MS milliseconds. `set_epoch_deadline` +// takes a tick count, so `max_execution_time_ms / EPOCH_TICK_MS` converts the +// operator-visible millisecond budget into the tick count wasmtime expects. +// If this interval changes, the conversion below remains correct automatically. +const EPOCH_TICK_MS: u64 = 1; + +// 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::*; +} + +/// Returns true if `actual` matches `pattern`. +/// - `*.example.com` matches `api.example.com` and `deep.api.example.com`, but NOT `example.com`. +/// - `example.com` matches only `example.com` exactly. +fn host_matches(actual: &str, pattern: &str) -> bool { + if let Some(suffix) = pattern.strip_prefix("*.") { + // actual must end with ".suffix" — the dot is required so "notexample.com" + // does not match "*.example.com" even though it ends with "example.com". + let dot_suffix = format!(".{}", suffix); + actual.ends_with(dot_suffix.as_str()) + } else { + actual == pattern + } +} + +/// Intercepts outbound HTTP requests from the WASM plugin and enforces the network policy. +/// Each rule can constrain the host pattern, allowed ports, URI scheme, and HTTP methods. +struct NetworkPolicy { + allowed_hosts: Arc>, +} + +impl WasiHttpHooks for NetworkPolicy { + fn send_request( + &mut self, + request: hyper::Request, + config: OutgoingRequestConfig, + ) -> HttpResult { + let uri = request.uri(); + let host = uri + .authority() + .map(|a| a.host().to_string()) + .unwrap_or_default(); + let port = uri.authority().and_then(|a| a.port_u16()); + let scheme = uri.scheme_str().unwrap_or("https"); + let method = request.method().as_str(); + + // Find the first rule whose host pattern matches. + let rule = match self.allowed_hosts.iter().find(|r| host_matches(&host, &r.host)) { + Some(r) => r, + None => return Err(ErrorCode::HttpRequestDenied.into()), + }; + + // Port check: infer default port from scheme when the URI has none. + if !rule.ports.is_empty() { + let req_port = port.unwrap_or(if scheme == "https" { 443 } else { 80 }); + if !rule.ports.contains(&req_port) { + return Err(ErrorCode::HttpRequestDenied.into()); + } + } + + // Scheme check. + if !rule.schemes.is_empty() && !rule.schemes.iter().any(|s| s == scheme) { + return Err(ErrorCode::HttpRequestDenied.into()); + } + + // Method check (case-insensitive). + if !rule.methods.is_empty() + && !rule + .methods + .iter() + .any(|m| m.eq_ignore_ascii_case(method)) + { + 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, + plugin_name: String, +} + +impl cpex::plugin::host_logging::Host for WasmPluginState { + fn log(&mut self, level: cpex::plugin::host_logging::LogLevel, message: String) { + use cpex::plugin::host_logging::LogLevel; + match level { + LogLevel::Trace => tracing::trace!(plugin = %self.plugin_name, "{}", message), + LogLevel::Debug => tracing::debug!(plugin = %self.plugin_name, "{}", message), + LogLevel::Info => tracing::info!(plugin = %self.plugin_name, "{}", message), + LogLevel::Warn => tracing::warn!(plugin = %self.plugin_name, "{}", message), + LogLevel::Error => tracing::error!(plugin = %self.plugin_name, "{}", message), + } + } +} + +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, + /// Fuel budget per invocation (reset at the start of each call) + fuel_per_invocation: 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, +} + +/// A shared engine + linker + epoch ticker that multiple `SandboxManager` instances +/// can use. Create one per factory, not one per plugin. +pub struct SharedEngine { + engine: Engine, + linker: Linker, +} + +impl SharedEngine { + /// Create a shared engine with WASI, HTTP, and host-logging linked. + /// Spawns a single epoch ticker thread for all plugins using this engine. + 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)?; + cpex::plugin::host_logging::add_to_linker::< + WasmPluginState, + wasmtime::component::HasSelf, + >(&mut linker, |state| state)?; + + let engine_clone = engine.clone(); + #[allow(clippy::disallowed_methods)] + std::thread::spawn(move || loop { + std::thread::sleep(std::time::Duration::from_millis(EPOCH_TICK_MS)); + engine_clone.increment_epoch(); + }); + + Ok(Self { engine, linker }) + } +} + +impl SandboxManager { + /// Create a new SandboxManager with its own engine and epoch ticker. + /// Prefer `with_shared_engine` when loading multiple plugins. + pub fn new() -> Result { + let shared = SharedEngine::new()?; + Ok(Self { + engine: shared.engine, + linker: shared.linker, + instance: None, + }) + } + + /// Create a SandboxManager backed by a shared engine. + /// All plugins sharing an engine use one epoch ticker thread. + pub fn with_shared_engine(shared: &SharedEngine) -> Self { + Self { + engine: shared.engine.clone(), + linker: shared.linker.clone(), + 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>, + plugin_name: &str, + ) -> Result<()> { + tracing::debug!(plugin = %plugin_name, path = %wasm_path.display(), "loading WASM plugin"); + let ctx = build_wasi_context(sandbox_policy)?; + 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(); + + let component = Component::from_file(&self.engine, wasm_path).map_err(|e| { + anyhow::anyhow!("failed to load wasm from {}: {}", wasm_path.display(), e) + })?; + + 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, + plugin_name: plugin_name.to_string(), + }, + ); + + // Apply memory/table limits + store.limiter(|state| &mut state.limits); + + // Fuel is set per-invocation (reset at the start of each call) to + // prevent long-lived plugins from silently degrading. The initial + // budget is set here so the plugin can instantiate. + let fuel_per_invocation = resources.max_fuel.unwrap_or(u64::MAX); + store + .set_fuel(fuel_per_invocation) + .map_err(|e| anyhow::anyhow!("failed to set fuel: {}", e))?; + + // Apply execution timeout via epoch deadline. + // Default is 5 seconds — safe for synchronous hooks. Plugins that + // perform outbound HTTP should set a higher value explicitly. + // Convert ms → ticks using EPOCH_TICK_MS so the unit relationship + // is explicit and survives any future change to the ticker interval. + let timeout_ms = resources.max_execution_time_ms.unwrap_or(5_000); + if resources.max_execution_time_ms.is_none() { + tracing::warn!( + plugin = %plugin_name, + "no explicit max_execution_time_ms configured — using default 5000ms" + ); + } + let epoch_deadline = timeout_ms / EPOCH_TICK_MS; + store.set_epoch_deadline(epoch_deadline); + store.epoch_deadline_trap(); + + let plugin = Plugin::instantiate_async(&mut store, &component, &self.linker) + .await + .map_err(|e| anyhow::anyhow!("failed to instantiate plugin: {}", e))?; + tracing::debug!(plugin = %plugin_name, "plugin instantiated"); + + self.instance = Some(WasmPluginInstance { + store, + plugin, + epoch_deadline, + fuel_per_invocation, + }); + + Ok(()) + } + + /// Invoke the loaded plugin's handle-hook function. + /// Both fuel and epoch deadline are reset per invocation so each call + /// gets a fresh budget — no silent degradation over time. + pub async fn invoke( + &mut self, + hook_name: &str, + payload: types::HookPayload, + extensions: types::Extensions, + ctx: types::PluginContext, + ) -> Result { + let instance = self.instance.as_mut().with_context(|| "no plugin loaded")?; + + // Reset fuel per invocation — each call gets a fresh budget + instance + .store + .set_fuel(instance.fuel_per_invocation) + .map_err(|e| anyhow::anyhow!("failed to reset fuel: {}", e))?; + + // Reset epoch deadline per invocation (timeout is per-call) + instance.store.set_epoch_deadline(instance.epoch_deadline); + + let result = instance + .plugin + .call_handle_hook(&mut instance.store, hook_name, &payload, &extensions, &ctx) + .await; + + 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() + } +} + +#[cfg(test)] +mod tests { + use super::host_matches; + + #[test] + fn exact_match_passes() { + assert!(host_matches("example.com", "example.com")); + } + + #[test] + fn exact_match_rejects_subdomain() { + assert!(!host_matches("api.example.com", "example.com")); + } + + #[test] + fn exact_match_rejects_unrelated_host() { + assert!(!host_matches("other.com", "example.com")); + } + + #[test] + fn wildcard_matches_direct_subdomain() { + assert!(host_matches("api.example.com", "*.example.com")); + } + + #[test] + fn wildcard_matches_deep_subdomain() { + assert!(host_matches("deep.api.example.com", "*.example.com")); + } + + #[test] + fn wildcard_rejects_bare_domain() { + // *.example.com must not match example.com itself + assert!(!host_matches("example.com", "*.example.com")); + } + + #[test] + fn wildcard_rejects_unrelated_host() { + assert!(!host_matches("other.com", "*.example.com")); + } + + #[test] + fn wildcard_rejects_partial_suffix_without_dot() { + // "notexample.com" should not match "*.example.com" + assert!(!host_matches("notexample.com", "*.example.com")); + } +} diff --git a/crates/cpex-wasm-host/tests/test_custom_payload_pipeline.rs b/crates/cpex-wasm-host/tests/test_custom_payload_pipeline.rs new file mode 100644 index 00000000..06f088f0 --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_custom_payload_pipeline.rs @@ -0,0 +1,322 @@ +// Location: ./crates/cpex-wasm-host/tests/test_custom_payload_pipeline.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration test: verifies the custom-payload WASM plugin pipeline end-to-end. +//! +//! Loads 4 real `.wasm` plugins (tool-invoke-checker, pii-guard, remote-authz, +//! audit-logger-custom) that use a user-defined `ToolInvokePayload` routed +//! through HookPayload::Custom. Tests the full PluginManager → WasmPluginFactory +//! → SandboxManager → guest handler → result path. +//! +//! Requires: all 4 custom-payload `.wasm` binaries built and staged. +//! Run `make build-all-plugins` from crates/cpex-wasm-host first. + +use std::path::PathBuf; +use std::sync::{Arc, Once}; + +use serde::{Deserialize, Serialize}; + +use cpex_core::config::parse_config; +use cpex_core::context::PluginContextTable; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::meta::MetaExtension; +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +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"; +} + +fn wasm_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm") +} + +fn check_binaries_exist() { + let dir = wasm_dir(); + for name in &[ + "tool-invoke-checker.wasm", + "pii-guard.wasm", + "remote-authz.wasm", + "audit-logger-custom.wasm", + ] { + let path = dir.join(name); + assert!( + path.exists(), + "WASM binary not found: {}. Run `make build-all-plugins` from crates/cpex-wasm-host first.", + path.display() + ); + } +} + +fn make_extensions(tool_name: &str) -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(tool_name.into()), + ..Default::default() + })), + ..Default::default() + } +} + +async fn setup_manager() -> PluginManager { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_plugin_demo.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + let yaml = std::fs::read_to_string(&config_path).unwrap(); + let cpex_config = parse_config(&yaml).unwrap(); + + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://tool-invoke-checker.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone()).expect("engine")), + ); + mgr.register_factory( + "wasm://pii-guard.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone()).expect("engine")), + ); + mgr.register_factory( + "wasm://remote-authz.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone()).expect("engine")), + ); + mgr.register_factory( + "wasm://audit-logger-custom.wasm", + Box::new(WasmPluginFactory::new(wasm_dir, registry).expect("engine")), + ); + + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_identity_resolver_denies_empty_user() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "".into(), + arguments: "".into(), + }; + let ext = make_extensions("list_departments"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(!result.continue_processing); + let violation = result.violation.as_ref().unwrap(); + assert_eq!(violation.code, "no_identity"); + assert_eq!(violation.plugin_name.as_deref(), Some("identity-resolver")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_pii_guard_denies_without_clearance() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_extensions("get_compensation"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(!result.continue_processing); + let violation = result.violation.as_ref().unwrap(); + assert_eq!(violation.code, "pii_access_denied"); + assert_eq!(violation.plugin_name.as_deref(), Some("pii-guard")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_pii_guard_allows_with_clearance() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_extensions("get_compensation"); + let mut ctx_table = PluginContextTable::new(); + ctx_table + .global_state + .insert("pii_clearance".into(), serde_json::Value::Bool(true)); + + let (result, bg) = mgr + .invoke::(payload, ext, Some(ctx_table)) + .await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing); + assert!(result.violation.is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_remote_authz_allows_user_in_acl() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "alice".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_extensions("query_external_data"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_remote_authz_denies_user_not_in_acl() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "charlie".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_extensions("query_external_data"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(!result.continue_processing); + let violation = result.violation.as_ref().unwrap(); + assert_eq!(violation.code, "remote_authz_denied"); + assert_eq!(violation.plugin_name.as_deref(), Some("remote-authz")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_non_pii_tool_allowed() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "bob".into(), + arguments: "".into(), + }; + let ext = make_extensions("list_departments"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_context_table_threads_across_hooks() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + // Pre-invoke with clearance + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_extensions("get_compensation"); + let mut ctx_table = PluginContextTable::new(); + ctx_table + .global_state + .insert("pii_clearance".into(), serde_json::Value::Bool(true)); + + let (pre_result, bg) = mgr + .invoke::(payload, ext, Some(ctx_table)) + .await; + bg.wait_for_background_tasks().await; + assert!(pre_result.continue_processing); + + // Post-invoke with threaded context table + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_extensions("get_compensation"); + let (post_result, bg) = mgr + .invoke::(payload, ext, Some(pre_result.context_table)) + .await; + bg.wait_for_background_tasks().await; + + assert!(post_result.continue_processing); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_wildcard_route_allowed() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "unknown_tool_xyz".into(), + user: "bob".into(), + arguments: "x=1".into(), + }; + let ext = make_extensions("unknown_tool_xyz"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing); +} diff --git a/crates/cpex-wasm-host/tests/test_execution_modes.rs b/crates/cpex-wasm-host/tests/test_execution_modes.rs new file mode 100644 index 00000000..be60f89d --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_execution_modes.rs @@ -0,0 +1,154 @@ +// Location: ./crates/cpex-wasm-host/tests/test_execution_modes.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration tests: verifies concurrent and fire_and_forget execution modes +//! work correctly through the WASM plugin pipeline. +//! +//! Requires: noop.wasm built and staged. +//! Run `make build-test-plugins` from crates/cpex-wasm-host first. + +use std::path::PathBuf; +use std::sync::{Arc, Once}; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::config::parse_config; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::meta::MetaExtension; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm") +} + +fn check_binary_exists() { + let path = wasm_dir().join("noop.wasm"); + assert!( + path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` first.", + path.display() + ); +} + +fn make_extensions(tool_name: &str) -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(tool_name.into()), + ..Default::default() + })), + ..Default::default() + } +} + +async fn setup_manager() -> PluginManager { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_execution_modes_test.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + let yaml = std::fs::read_to_string(&config_path).unwrap(); + let cpex_config = parse_config(&yaml).unwrap(); + + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://noop.wasm", + Box::new(WasmPluginFactory::new(wasm_dir, registry).expect("engine")), + ); + + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +// ── Concurrent mode ───────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_concurrent_plugins_all_allow() { + init_tracing(); + check_binary_exists(); + let mgr = setup_manager().await; + let ext = make_extensions("some_tool"); + + let (result, bg) = mgr.invoke::( + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message { + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), + role: cpex_core::cmf::Role::Assistant, + content: vec![cpex_core::cmf::ContentPart::Text { + text: "hello".into(), + }], + channel: None, + }, + }, + ext, + None, + ).await; + bg.wait_for_background_tasks().await; + + assert!( + result.continue_processing, + "concurrent noop plugins should all allow" + ); + assert!(result.violation.is_none()); +} + +// ── Fire-and-forget mode ──────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_fire_and_forget_returns_immediately() { + init_tracing(); + check_binary_exists(); + let mgr = setup_manager().await; + + let payload = cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message { + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), + role: cpex_core::cmf::Role::Assistant, + content: vec![cpex_core::cmf::ContentPart::Text { + text: "test".into(), + }], + channel: None, + }, + }; + let ext = make_extensions("some_tool"); + + // Fire the post_invoke hook which has a fire_and_forget plugin + let (result, bg) = mgr.invoke_named::( + "cmf.tool_post_invoke", + payload, + ext, + None, + ).await; + + // Pipeline should return immediately with allow (FAF doesn't block) + assert!( + result.continue_processing, + "fire_and_forget should not block the pipeline" + ); + assert!(result.violation.is_none()); + + // Background tasks should complete without error + bg.wait_for_background_tasks().await; +} diff --git a/crates/cpex-wasm-host/tests/test_identity_resolve_e2e.rs b/crates/cpex-wasm-host/tests/test_identity_resolve_e2e.rs new file mode 100644 index 00000000..2c50cd62 --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_identity_resolve_e2e.rs @@ -0,0 +1,191 @@ +// Location: ./crates/cpex-wasm-host/tests/test_identity_resolve_e2e.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration test: verifies the identity.resolve hook routes through the +//! WASM boundary end-to-end. +//! +//! The host sends a native IdentityPayload (HookPayload::Identity variant), +//! the plugin macro routes it to the registered IdentityHook handler, and the +//! resolved subject is returned via the pipeline result. +//! +//! Requires: identity-checker.wasm built and staged. +//! Run `make build-all-plugins` from crates/cpex-wasm-host first. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Once}; + +use cpex_core::config::parse_config; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::security::SubjectType; +use cpex_core::identity::{ + IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE, +}; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm") +} + +fn check_binary_exists() { + let path = wasm_dir().join("identity-checker.wasm"); + assert!( + path.exists(), + "WASM binary not found: {}. Run `make build-all-plugins` first.", + path.display() + ); +} + +async fn setup_manager() -> PluginManager { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_identity_resolve_test.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + let yaml = std::fs::read_to_string(&config_path).unwrap(); + let cpex_config = parse_config(&yaml).unwrap(); + + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://identity-checker.wasm", + Box::new(WasmPluginFactory::new(wasm_dir, registry).expect("engine")), + ); + + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_identity_resolve_extracts_subject_from_header() { + init_tracing(); + check_binary_exists(); + let mgr = setup_manager().await; + + let payload = IdentityPayload::new("", TokenSource::Bearer) + .with_headers(HashMap::from([ + ("x-user-id".into(), "alice-123".into()), + ("authorization".into(), "Bearer eyJ...".into()), + ])); + + let (result, bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + payload, + Extensions::default(), + None, + ) + .await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing, "identity resolve should allow"); + + let resolved = IdentityPayload::from_pipeline_result(&result) + .expect("pipeline should return a modified IdentityPayload"); + + let subject = resolved + .subject + .as_ref() + .expect("subject should be resolved from x-user-id header"); + + assert_eq!(subject.id.as_deref(), Some("alice-123")); + assert_eq!(subject.subject_type, Some(SubjectType::User)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_identity_resolve_passes_through_when_no_header() { + init_tracing(); + check_binary_exists(); + let mgr = setup_manager().await; + + let payload = IdentityPayload::new("", TokenSource::Bearer) + .with_headers(HashMap::from([ + ("authorization".into(), "Bearer eyJ...".into()), + ])); + + let (result, bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + payload, + Extensions::default(), + None, + ) + .await; + bg.wait_for_background_tasks().await; + + assert!( + result.continue_processing, + "missing x-user-id should still allow (pass-through)" + ); + + // No subject resolved — either no modified payload or subject is None + match IdentityPayload::from_pipeline_result(&result) { + Some(p) => assert!( + p.subject.is_none(), + "no x-user-id header means no subject should be resolved" + ), + None => {} // no modified payload means pass-through — valid + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_identity_resolve_skips_when_subject_already_set() { + init_tracing(); + check_binary_exists(); + let mgr = setup_manager().await; + + let mut payload = IdentityPayload::new("", TokenSource::Bearer) + .with_headers(HashMap::from([ + ("x-user-id".into(), "should-be-ignored".into()), + ])); + payload.subject = Some(cpex_core::extensions::security::SubjectExtension { + id: Some("pre-existing-user".into()), + subject_type: Some(SubjectType::Service), + ..Default::default() + }); + + let (result, bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + payload, + Extensions::default(), + None, + ) + .await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing, "should allow when subject already set"); + + // Subject already present — plugin should pass through without overwriting + match IdentityPayload::from_pipeline_result(&result) { + Some(p) => { + let subject = p.subject.as_ref().expect("subject should still be present"); + assert_eq!(subject.id.as_deref(), Some("pre-existing-user")); + assert_eq!(subject.subject_type, Some(SubjectType::Service)); + } + None => {} // no modification means original subject preserved — valid + } +} 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..e588a297 --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_policy_loader.rs @@ -0,0 +1,110 @@ +// Location: ./crates/cpex-wasm-host/tests/test_policy_loader.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Integration tests for policy_loader. +// Validates that the sandbox policy in config/config_policy_test_fixture.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_policy_test_fixture.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_policy_test_fixture.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_policy_test_fixture.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_policy_test_fixture.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/tests/test_sandbox_env.rs b/crates/cpex-wasm-host/tests/test_sandbox_env.rs new file mode 100644 index 00000000..edca4b0d --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_sandbox_env.rs @@ -0,0 +1,204 @@ +// Location: ./crates/cpex-wasm-host/tests/test_sandbox_env.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration test: verifies WASM sandbox environment variable isolation. +//! +//! Loads a real `.wasm` plugin that reads env vars (HOME, PATH, SECRET_API_KEY). +//! With no env policy, all must be empty. With a selective policy, only the +//! explicitly allowed variable should be visible. +//! +//! Requires: `wasm/env-test.wasm` built from cpex-wasm-plugin with `--features env-test` + +use std::path::PathBuf; +use std::sync::Once; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::extensions::container::Extensions; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::policy_loader::SandboxPolicy; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/env-test.wasm") +} + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "env_check".into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +fn extract_context( + result: &cpex_wasm_host::sandbox_manager::types::HookResult, +) -> std::collections::HashMap { + result + .modified_context + .as_ref() + .expect("plugin should write context") + .local_state + .iter() + .map(|e| (e.key.clone(), e.value.clone())) + .collect() +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_see_env_vars_without_policy() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Set a host env var that the plugin will try to read + std::env::set_var("SECRET_API_KEY", "super-secret-value"); + + // Load with NO env policy (deny-all) + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, None, "env-test").await.unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing); + + let entries = extract_context(&result); + + // HOME must be empty (not exposed) + let home = entries.get("env_HOME").unwrap_or(&String::new()).clone(); + assert_eq!( + home, "\"\"", + "SANDBOX ESCAPE: plugin can see HOME='{}'", + home + ); + + // PATH must be empty + let path_val = entries.get("env_PATH").unwrap_or(&String::new()).clone(); + assert_eq!( + path_val, "\"\"", + "SANDBOX ESCAPE: plugin can see PATH='{}'", + path_val + ); + + // SECRET_API_KEY must be empty + let secret = entries + .get("env_SECRET_API_KEY") + .unwrap_or(&String::new()) + .clone(); + assert_eq!( + secret, "\"\"", + "SANDBOX ESCAPE: plugin can see SECRET_API_KEY='{}'", + secret + ); + + // Clean up + std::env::remove_var("SECRET_API_KEY"); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_sees_only_allowed_env_var() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Set the allowed var and a secret var on the host + std::env::set_var("CPEX_TEST_ALLOWED", "hello-from-host"); + std::env::set_var("SECRET_API_KEY", "super-secret-value"); + + // Policy allows ONLY CPEX_TEST_ALLOWED + let policy = SandboxPolicy { + allowed_env: vec!["CPEX_TEST_ALLOWED".to_string()], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "env-test-selective") + .await + .unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing); + + let entries = extract_context(&result); + + // CPEX_TEST_ALLOWED should be visible + let allowed = entries + .get("env_CPEX_TEST_ALLOWED") + .unwrap_or(&String::new()) + .clone(); + assert_eq!( + allowed, "\"hello-from-host\"", + "allowed env var should be visible, got: {}", + allowed + ); + + // HOME must still be empty (not in allowed list) + let home = entries.get("env_HOME").unwrap_or(&String::new()).clone(); + assert_eq!(home, "\"\"", "HOME should be hidden, got: {}", home); + + // SECRET_API_KEY must still be empty + let secret = entries + .get("env_SECRET_API_KEY") + .unwrap_or(&String::new()) + .clone(); + assert_eq!( + secret, "\"\"", + "SANDBOX ESCAPE: plugin sees SECRET_API_KEY despite not being in allowed_env" + ); + + // Clean up + std::env::remove_var("CPEX_TEST_ALLOWED"); + std::env::remove_var("SECRET_API_KEY"); +} diff --git a/crates/cpex-wasm-host/tests/test_sandbox_isolation.rs b/crates/cpex-wasm-host/tests/test_sandbox_isolation.rs new file mode 100644 index 00000000..0ae9eda3 --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_sandbox_isolation.rs @@ -0,0 +1,183 @@ +// Location: ./crates/cpex-wasm-host/tests/test_sandbox_isolation.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration test: verifies WASM sandbox filesystem isolation. +//! +//! Loads a real `.wasm` plugin that attempts to read `/etc/passwd`. +//! With no filesystem policy, the read must fail (sandbox denies access). +//! This proves the isolation isn't just config-level — it's enforced at runtime. +//! +//! Requires: `wasm/fs-test.wasm` built from cpex-wasm-plugin with `--features fs-test` + +use std::path::PathBuf; +use std::sync::Once; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::extensions::container::Extensions; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/fs-test.wasm") +} + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "read_file".into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_read_etc_passwd_without_filesystem_policy() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Load plugin with NO filesystem policy (deny-all) + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, None, "fs-test").await.unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + // The plugin should have executed (continue_processing = true) + assert!(result.continue_processing, "plugin should return allow"); + + // Check the context — plugin writes fs_read_success into local_state + let ctx = result + .modified_context + .expect("plugin should write context"); + let local_entries: std::collections::HashMap = ctx + .local_state + .into_iter() + .map(|e| (e.key, e.value)) + .collect(); + + let success_value = local_entries + .get("fs_read_success") + .expect("plugin should set fs_read_success in context"); + + // The read MUST have failed — sandbox denied it + assert_eq!( + success_value, + "false", + "SANDBOX ESCAPE: plugin successfully read /etc/passwd! fs_read_success={}, error={}", + success_value, + local_entries + .get("fs_read_error") + .unwrap_or(&"".to_string()) + ); + + // Verify the error message indicates permission/access denial + let error_msg = local_entries + .get("fs_read_error") + .expect("plugin should set fs_read_error"); + assert!( + error_msg.contains("denied") + || error_msg.contains("permission") + || error_msg.contains("not found") + || error_msg.contains("No such") + || error_msg.contains("Capabilities insufficient"), + "unexpected error message: {}", + error_msg + ); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_read_etc_passwd_with_unrelated_filesystem_policy() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Load plugin with filesystem policy that only allows /tmp + let policy = cpex_wasm_host::policy_loader::SandboxPolicy { + allowed_filesystem: vec![cpex_wasm_host::policy_loader::FilesystemRule { + dir: Some("/tmp".to_string()), + file: None, + permission: "read-only".to_string(), + }], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "fs-test-restricted") + .await + .unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing); + + let ctx = result + .modified_context + .expect("plugin should write context"); + let local_entries: std::collections::HashMap = ctx + .local_state + .into_iter() + .map(|e| (e.key, e.value)) + .collect(); + + let success_value = local_entries + .get("fs_read_success") + .expect("plugin should set fs_read_success"); + + // Even with /tmp allowed, /etc/passwd must still be denied + assert_eq!( + success_value, "false", + "SANDBOX ESCAPE: plugin read /etc/passwd despite only /tmp being allowed!" + ); +} diff --git a/crates/cpex-wasm-host/tests/test_sandbox_network.rs b/crates/cpex-wasm-host/tests/test_sandbox_network.rs new file mode 100644 index 00000000..d1c40812 --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_sandbox_network.rs @@ -0,0 +1,352 @@ +// Location: ./crates/cpex-wasm-host/tests/test_sandbox_network.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration tests: verifies WASM sandbox network isolation and policy enforcement. +//! +//! Two sets of tests: +//! +//! 1. DNS isolation (net-test plugin) — proves the raw socket / DNS layer is blocked. +//! Requires: `wasm/net-test.wasm` built with `--features net-test` +//! +//! 2. WASI HTTP enforcement (net-http-test plugin) — proves that port, scheme, and +//! HTTP method constraints in NetworkRule are enforced by WasiHttpHooks::send_request. +//! Requires: `wasm/net-http-test.wasm` built with `--features net-http-test` + +use std::path::PathBuf; +use std::sync::Once; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::extensions::container::Extensions; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/net-test.wasm") +} + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "net_check".into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_access_network_without_policy() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Load with NO network policy (deny-all) + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, None, "net-test").await.unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing, "plugin should return allow"); + + let ctx = result + .modified_context + .expect("plugin should write context"); + let local_entries: std::collections::HashMap = ctx + .local_state + .into_iter() + .map(|e| (e.key, e.value)) + .collect(); + + let net_access = local_entries + .get("net_access") + .expect("plugin should set net_access"); + + // Network access must be denied in sandbox + assert_eq!( + net_access, "\"denied\"", + "SANDBOX ESCAPE: plugin accessed network without allowlist! net_access={}", + net_access + ); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_access_network_with_unrelated_allowlist() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Allow only "internal.example.com" — httpbin.org should still be denied + let policy = cpex_wasm_host::policy_loader::SandboxPolicy { + allowed_network: vec![cpex_wasm_host::policy_loader::NetworkRule { + host: "internal.example.com".to_string(), + ..Default::default() + }], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "net-test-restricted") + .await + .unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing); + + let ctx = result + .modified_context + .expect("plugin should write context"); + let local_entries: std::collections::HashMap = ctx + .local_state + .into_iter() + .map(|e| (e.key, e.value)) + .collect(); + + let net_access = local_entries + .get("net_access") + .expect("plugin should set net_access"); + + assert_eq!( + net_access, "\"denied\"", + "SANDBOX ESCAPE: plugin resolved DNS for httpbin.org despite allowlist being [internal.example.com]!" + ); +} + +// ── WASI HTTP enforcement tests (net-http-test plugin) ──────────────────────── +// +// These tests prove that port, scheme, and method constraints in NetworkRule +// are enforced by WasiHttpHooks::send_request. They use the net-http-test +// plugin which makes real WASI outgoing HTTP calls. + +fn http_wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/net-http-test.wasm") +} + +fn make_http_payload(url: &str, method: &str) -> MessagePayload { + let mut args = std::collections::HashMap::new(); + args.insert("url".to_string(), serde_json::json!(url)); + args.insert("method".to_string(), serde_json::json!(method)); + + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_http".into(), + name: "http_check".into(), + arguments: args, + namespace: None, + }, + }], + channel: None, + }, + } +} + +async fn invoke_http_plugin( + mgr: &mut cpex_wasm_host::sandbox_manager::SandboxManager, + url: &str, + method: &str, +) -> String { + use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, + }; + let payload = make_http_payload(url, method); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + result + .modified_context + .expect("plugin should write context") + .local_state + .into_iter() + .find(|e| e.key == "http_result") + .map(|e| e.value) + .unwrap_or_default() +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_http_request_allowed_when_host_matches() { + init_tracing(); + let path = http_wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` first.", path.display()); + + let policy = cpex_wasm_host::policy_loader::SandboxPolicy { + allowed_network: vec![cpex_wasm_host::policy_loader::NetworkRule { + host: "example.com".to_string(), + ..Default::default() + }], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "net-http-test-allow").await.unwrap(); + + let result = invoke_http_plugin(&mut mgr, "https://example.com/", "GET").await; + assert_eq!(result, "\"allowed\"", "request to allowed host should pass, got: {}", result); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_http_request_denied_for_blocked_port() { + init_tracing(); + let path = http_wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` first.", path.display()); + + // Allow example.com but only on port 443 — port 8080 must be denied. + let policy = cpex_wasm_host::policy_loader::SandboxPolicy { + allowed_network: vec![cpex_wasm_host::policy_loader::NetworkRule { + host: "example.com".to_string(), + ports: vec![443], + ..Default::default() + }], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "net-http-test-port").await.unwrap(); + + let result = invoke_http_plugin(&mut mgr, "https://example.com:8080/", "GET").await; + assert_eq!(result, "\"denied\"", "request on blocked port 8080 must be denied, got: {}", result); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_http_request_denied_for_blocked_scheme() { + init_tracing(); + let path = http_wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` first.", path.display()); + + // Default schemes = ["https"] only — plain HTTP must be denied. + let policy = cpex_wasm_host::policy_loader::SandboxPolicy { + allowed_network: vec![cpex_wasm_host::policy_loader::NetworkRule { + host: "example.com".to_string(), + ..Default::default() // schemes defaults to ["https"] + }], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "net-http-test-scheme").await.unwrap(); + + let result = invoke_http_plugin(&mut mgr, "http://example.com/", "GET").await; + assert_eq!(result, "\"denied\"", "plain HTTP must be denied when only https is allowed, got: {}", result); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_http_request_denied_for_blocked_method() { + init_tracing(); + let path = http_wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` first.", path.display()); + + // Only GET allowed — POST must be denied. + let policy = cpex_wasm_host::policy_loader::SandboxPolicy { + allowed_network: vec![cpex_wasm_host::policy_loader::NetworkRule { + host: "example.com".to_string(), + methods: vec!["GET".to_string()], + ..Default::default() + }], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "net-http-test-method").await.unwrap(); + + let result = invoke_http_plugin(&mut mgr, "https://example.com/", "POST").await; + assert_eq!(result, "\"denied\"", "POST must be denied when only GET is allowed, got: {}", result); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_http_request_denied_for_unlisted_host() { + init_tracing(); + let path = http_wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` first.", path.display()); + + // Allow example.com — request to other.com must be denied. + let policy = cpex_wasm_host::policy_loader::SandboxPolicy { + allowed_network: vec![cpex_wasm_host::policy_loader::NetworkRule { + host: "example.com".to_string(), + ..Default::default() + }], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "net-http-test-unlisted").await.unwrap(); + + let result = invoke_http_plugin(&mut mgr, "https://other.com/", "GET").await; + assert_eq!(result, "\"denied\"", "request to unlisted host must be denied, got: {}", result); +} diff --git a/crates/cpex-wasm-host/tests/test_sandbox_resource_limits.rs b/crates/cpex-wasm-host/tests/test_sandbox_resource_limits.rs new file mode 100644 index 00000000..70e6e89d --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_sandbox_resource_limits.rs @@ -0,0 +1,235 @@ +// Location: ./crates/cpex-wasm-host/tests/test_sandbox_resource_limits.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration tests: verifies resource limits actually trap a running WASM plugin. +//! +//! Each test loads the resource-test plugin with a deliberately tiny limit, +//! invokes it in the mode that exercises that limit, and asserts the invocation +//! returns the expected error variant — proving the limit is enforced at runtime, +//! not just parsed from YAML. +//! +//! Requires: `wasm/resource-test.wasm` built with `make build-test-plugins` + +use std::path::PathBuf; +use std::sync::Once; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::extensions::container::Extensions; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::policy_loader::{ResourceLimits, SandboxPolicy}; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/resource-test.wasm") +} + +fn make_payload(mode: &str) -> MessagePayload { + let mut args = std::collections::HashMap::new(); + args.insert("mode".to_string(), serde_json::json!(mode)); + + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_resource".into(), + name: "resource_test".into(), + arguments: args, + namespace: None, + }, + }], + channel: None, + }, + } +} + +async fn load_with_limits(resources: ResourceLimits) -> SandboxManager { + let path = wasm_path(); + assert!( + path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display() + ); + + let policy = SandboxPolicy { + resources, + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "resource-test") + .await + .unwrap(); + mgr +} + +// ── Fuel ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_fuel_limit_traps_plugin() { + init_tracing(); + + // 10 000 fuel units — enough to instantiate but far too little for the loop. + let mut mgr = load_with_limits(ResourceLimits { + max_fuel: Some(10_000), + max_execution_time_ms: Some(10_000), + ..Default::default() + }) + .await; + + let payload = make_payload("burn_fuel"); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let err = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap_err(); + + // Wasmtime 45 wraps trap messages in nested error chains. Walk the + // full chain and collect all messages for assertion. + let mut messages = vec![format!("{}", err)]; + { + let mut source: Option<&dyn std::error::Error> = err.source(); + while let Some(s) = source { + messages.push(format!("{}", s)); + source = s.source(); + } + } + let combined = messages.join(" | ").to_lowercase(); + assert!( + combined.contains("fuel") || combined.contains("wasm") || combined.contains("error while executing"), + "expected fuel/wasm trap error, got: {:?}", + messages + ); +} + +// ── Epoch timeout ───────────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_epoch_timeout_traps_plugin() { + init_tracing(); + + // The epoch timeout test verifies that a long-running plugin is interrupted + // by the epoch deadline. We use a generous fuel budget so the loop doesn't + // exhaust fuel first, and a short timeout to trigger the epoch interrupt. + // The burn_fuel mode is used with enough fuel for ~1 second of execution but + // a 200ms epoch deadline — ensuring the epoch fires before fuel runs out. + let mut mgr = load_with_limits(ResourceLimits { + max_execution_time_ms: Some(200), + max_fuel: Some(500_000_000), + ..Default::default() + }) + .await; + + // Allow the epoch ticker thread to start before invoking. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + + let payload = make_payload("burn_fuel"); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let start = std::time::Instant::now(); + let err = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap_err(); + let elapsed = start.elapsed(); + + let mut messages = vec![format!("{}", err)]; + { + let mut source: Option<&dyn std::error::Error> = err.source(); + while let Some(s) = source { + messages.push(format!("{}", s)); + source = s.source(); + } + } + let combined = messages.join(" | ").to_lowercase(); + + // The invocation should fail within ~200ms (not seconds) due to epoch interrupt. + // Accept either epoch or fuel message — the key assertion is wall-clock time. + assert!( + elapsed.as_millis() < 2000, + "expected timeout within 2s, but took {:?}", + elapsed + ); + assert!( + combined.contains("epoch") || combined.contains("interrupt") + || combined.contains("deadline") || combined.contains("fuel") + || combined.contains("error while executing"), + "expected execution error, got: {:?}", + messages + ); +} + +// ── Memory cap ─────────────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_memory_limit_traps_plugin() { + init_tracing(); + + // 5 MB — the alloc_memory mode allocates in 1 MB chunks until OOM. + let mut mgr = load_with_limits(ResourceLimits { + max_memory_bytes: Some(5 * 1024 * 1024), + max_fuel: Some(u64::MAX), + max_execution_time_ms: Some(10_000), + ..Default::default() + }) + .await; + + let payload = make_payload("alloc_memory"); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let err = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap_err(); + + let mut messages = vec![format!("{}", err)]; + { + let mut source: Option<&dyn std::error::Error> = err.source(); + while let Some(s) = source { + messages.push(format!("{}", s)); + source = s.source(); + } + } + let combined = messages.join(" | ").to_lowercase(); + assert!( + combined.contains("memory") || combined.contains("grow") + || combined.contains("unreachable") || combined.contains("error while executing"), + "expected memory/execution error, got: {:?}", + messages + ); +} diff --git a/crates/cpex-wasm-host/tests/test_security_enforcement.rs b/crates/cpex-wasm-host/tests/test_security_enforcement.rs new file mode 100644 index 00000000..444165cf --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_security_enforcement.rs @@ -0,0 +1,541 @@ +// Location: ./crates/cpex-wasm-host/tests/test_security_enforcement.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Tests for defense-in-depth security enforcement in WasmBridgeHandler. +// +// These tests validate the four security checks: +// 1. Pre-invocation capability-based extension filtering +// 2. Post-invocation immutable tier validation +// 3. Post-invocation monotonic label enforcement +// 4. Post-invocation write authorization checking + +use std::collections::HashSet; +use std::sync::Arc; + +use cpex_core::extensions::{ + filter_extensions, DelegationExtension, Extensions, Guarded, HttpExtension, MonotonicSet, + OwnedExtensions, RequestExtension, SecurityExtension, +}; + +// --------------------------------------------------------------------------- +// Fix 1: Pre-invocation capability filtering +// --------------------------------------------------------------------------- + +#[test] +fn test_pre_filter_strips_ungated_extensions() { + let ext = build_full_extensions(); + let caps: HashSet = ["read_headers"].iter().map(|s| s.to_string()).collect(); + + let filtered = filter_extensions(&ext, &caps); + + // HTTP is visible (has read_headers) + assert!(filtered.http.is_some()); + // Security labels require read_labels — not granted + assert!(filtered.security.is_none() || filtered.security.as_ref().unwrap().labels.is_empty()); + // Agent requires read_agent — not granted + assert!(filtered.agent.is_none()); + // Delegation requires read_delegation — not granted + assert!(filtered.delegation.is_none()); + // Unrestricted immutable slots are always visible + assert!(filtered.request.is_some()); +} + +#[test] +fn test_pre_filter_allows_all_with_full_capabilities() { + let ext = build_full_extensions(); + let caps: HashSet = [ + "read_headers", + "read_labels", + "read_subject", + "read_agent", + "read_delegation", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + let filtered = filter_extensions(&ext, &caps); + + assert!(filtered.http.is_some()); + assert!(filtered.security.is_some()); + assert!(filtered.agent.is_some()); + assert!(filtered.delegation.is_some()); + assert!(filtered.request.is_some()); +} + +// --------------------------------------------------------------------------- +// Fix 2: Immutable tier validation +// --------------------------------------------------------------------------- + +#[test] +fn test_immutable_tier_violation_detected() { + let ext = build_full_extensions(); + let mut owned = ext.cow_copy(); + + // Tamper with an immutable slot by replacing the Arc pointer + owned.request = Some(Arc::new(RequestExtension { + request_id: Some("tampered".into()), + ..Default::default() + })); + + assert!(!ext.validate_immutable(&owned)); +} + +#[test] +fn test_immutable_tier_passes_when_arcs_preserved() { + let ext = build_full_extensions(); + let owned = ext.cow_copy(); + + assert!(ext.validate_immutable(&owned)); +} + +// --------------------------------------------------------------------------- +// Fix 3: Monotonic label enforcement +// --------------------------------------------------------------------------- + +#[test] +fn test_monotonic_violation_when_label_removed() { + let ext = build_extensions_with_labels(&["PII", "HIPAA"]); + let mut owned = ext.cow_copy(); + + // Replace security with only one label (removed HIPAA) + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set(["PII".to_string()].into_iter().collect()), + ..Default::default() + }; + owned.security = Some(new_sec); + + // With read_labels capability, this should be detected + let caps: HashSet = ["read_labels"].iter().map(|s| s.to_string()).collect(); + + let result = validate_monotonic(&ext, &owned, &caps); + assert!(!result, "should reject when label removed"); +} + +#[test] +fn test_monotonic_passes_when_labels_only_added() { + let ext = build_extensions_with_labels(&["PII"]); + let mut owned = ext.cow_copy(); + + // Add a label (superset of original) + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set( + ["PII".to_string(), "HIPAA".to_string()] + .into_iter() + .collect(), + ), + ..Default::default() + }; + owned.security = Some(new_sec); + + let caps: HashSet = ["read_labels"].iter().map(|s| s.to_string()).collect(); + + let result = validate_monotonic(&ext, &owned, &caps); + assert!(result, "should accept when labels only added"); +} + +#[test] +fn test_monotonic_not_enforced_without_read_labels() { + let ext = build_extensions_with_labels(&["PII", "HIPAA"]); + let mut owned = ext.cow_copy(); + + // Remove a label + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set(["PII".to_string()].into_iter().collect()), + ..Default::default() + }; + owned.security = Some(new_sec); + + // Without read_labels capability, plugin never saw labels — not a violation + let caps: HashSet = HashSet::new(); + + let result = validate_monotonic(&ext, &owned, &caps); + assert!( + result, + "should not enforce monotonic without read_labels cap" + ); +} + +// --------------------------------------------------------------------------- +// Fix 4: Write authorization checking +// --------------------------------------------------------------------------- + +#[test] +fn test_unauthorized_http_write_detected() { + let ext = build_full_extensions(); + let mut owned = ext.cow_copy(); + + // Modify HTTP headers + let mut new_http = HttpExtension::default(); + new_http + .request_headers + .insert("X-Injected".into(), "evil".into()); + owned.http = Some(Guarded::new(new_http)); + + // Plugin lacks write_headers capability + let caps: HashSet = ["read_headers"].iter().map(|s| s.to_string()).collect(); + + let result = validate_write_auth_http(&ext, &owned, &caps); + assert!( + !result, + "should reject HTTP write without write_headers cap" + ); +} + +#[test] +fn test_authorized_http_write_passes() { + let ext = build_full_extensions(); + let mut owned = ext.cow_copy(); + + // Modify HTTP headers + let mut new_http = HttpExtension::default(); + new_http + .request_headers + .insert("X-Processed".into(), "ok".into()); + owned.http = Some(Guarded::new(new_http)); + + // Plugin HAS write_headers capability + let caps: HashSet = ["read_headers", "write_headers"] + .iter() + .map(|s| s.to_string()) + .collect(); + + let result = validate_write_auth_http(&ext, &owned, &caps); + assert!(result, "should accept HTTP write with write_headers cap"); +} + +#[test] +fn test_unauthorized_labels_write_detected() { + let ext = build_extensions_with_labels(&["PII"]); + let mut owned = ext.cow_copy(); + + // Add a label without append_labels capability + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set( + ["PII".to_string(), "NEW".to_string()].into_iter().collect(), + ), + ..Default::default() + }; + owned.security = Some(new_sec); + + let caps: HashSet = ["read_labels"].iter().map(|s| s.to_string()).collect(); + + let result = validate_write_auth_labels(&ext, &owned, &caps); + assert!( + !result, + "should reject label write without append_labels cap" + ); +} + +#[test] +fn test_authorized_labels_write_passes() { + let ext = build_extensions_with_labels(&["PII"]); + let mut owned = ext.cow_copy(); + + // Add a label WITH append_labels capability + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set( + ["PII".to_string(), "NEW".to_string()].into_iter().collect(), + ), + ..Default::default() + }; + owned.security = Some(new_sec); + + let caps: HashSet = ["read_labels", "append_labels"] + .iter() + .map(|s| s.to_string()) + .collect(); + + let result = validate_write_auth_labels(&ext, &owned, &caps); + assert!(result, "should accept label write with append_labels cap"); +} + +#[test] +fn test_unauthorized_delegation_write_detected() { + let ext = build_full_extensions(); + let mut owned = ext.cow_copy(); + + // Modify delegation + let new_deleg = DelegationExtension { + delegated: true, + depth: 1, + ..Default::default() + }; + owned.delegation = Some(new_deleg); + + // Plugin lacks append_delegation capability + let caps: HashSet = ["read_delegation"].iter().map(|s| s.to_string()).collect(); + + let result = validate_write_auth_delegation(&ext, &owned, &caps); + assert!( + !result, + "should reject delegation write without append_delegation cap" + ); +} + +#[test] +fn test_no_modifications_skips_validation() { + let ext = build_full_extensions(); + let owned = ext.cow_copy(); + + // No changes — all checks should pass + let caps: HashSet = HashSet::new(); + + assert!(ext.validate_immutable(&owned)); + // Monotonic: no caps means no enforcement + assert!(validate_monotonic(&ext, &owned, &caps)); +} + +// --------------------------------------------------------------------------- +// Helpers — mirror the validation logic from factory.rs +// --------------------------------------------------------------------------- + +fn validate_monotonic( + original: &Extensions, + owned: &OwnedExtensions, + capabilities: &HashSet, +) -> bool { + if capabilities.contains("read_labels") { + if let (Some(ref orig_sec), Some(ref new_sec)) = (&original.security, &owned.security) { + if !new_sec.labels.is_superset(&orig_sec.labels) { + return false; + } + } + } + true +} + +fn validate_write_auth_http( + original: &Extensions, + owned: &OwnedExtensions, + capabilities: &HashSet, +) -> bool { + if !capabilities.contains("write_headers") { + if let Some(ref http_guarded) = owned.http { + let new_http = http_guarded.read(); + let http_changed = match original.http.as_ref() { + Some(orig) => { + new_http.request_headers != orig.request_headers + || new_http.response_headers != orig.response_headers + }, + None => { + !new_http.request_headers.is_empty() || !new_http.response_headers.is_empty() + }, + }; + if http_changed { + return false; + } + } + } + true +} + +fn validate_write_auth_labels( + original: &Extensions, + owned: &OwnedExtensions, + capabilities: &HashSet, +) -> bool { + if !capabilities.contains("append_labels") { + if let Some(ref new_sec) = owned.security { + let labels_changed = match original.security.as_ref() { + Some(orig) => new_sec.labels.len() != orig.labels.len(), + None => !new_sec.labels.is_empty(), + }; + if labels_changed { + return false; + } + } + } + true +} + +fn validate_write_auth_delegation( + original: &Extensions, + owned: &OwnedExtensions, + capabilities: &HashSet, +) -> bool { + if !capabilities.contains("append_delegation") { + if let Some(ref new_deleg) = owned.delegation { + let delegation_changed = match original.delegation.as_ref() { + Some(orig) => { + new_deleg.chain.len() != orig.chain.len() + || new_deleg.depth != orig.depth + || new_deleg.delegated != orig.delegated + }, + None => new_deleg.delegated || !new_deleg.chain.is_empty(), + }; + if delegation_changed { + return false; + } + } + } + true +} + +// --------------------------------------------------------------------------- +// Fix 5: Filtered slot preservation (hidden slots not nulled on writeback) +// --------------------------------------------------------------------------- + +#[test] +fn test_hidden_http_slot_preserved_when_plugin_modifies_labels() { + use cpex_core::extensions::filter_extensions; + use cpex_wasm_host::conversions::{ + native_extensions_to_wit, wit_hook_result_to_native_filtered, + }; + use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + + // Pipeline has HTTP headers + let ext = build_full_extensions(); + assert!(ext.http.is_some(), "precondition: HTTP exists in pipeline"); + + // Plugin only has label capabilities — no read_headers + let caps: HashSet = ["read_labels", "append_labels"] + .iter() + .map(|s| s.to_string()) + .collect(); + let filtered = filter_extensions(&ext, &caps); + assert!(filtered.http.is_none(), "precondition: HTTP filtered out"); + + // Simulate guest modifying labels and returning extensions + let mut wit_ext = native_extensions_to_wit(&filtered); + if let Some(ref mut s) = wit_ext.security { + s.labels.push("NEW_LABEL".to_string()); + } + + // Build a HookResult with modified extensions + let result = cpex_wasm_host::sandbox_manager::types::HookResult { + continue_processing: true, + modified_payload: None, + modified_extensions: Some(wit_ext), + modified_context: None, + violation: None, + metadata: None, + }; + + let registry = PayloadSerializerRegistry::new(); + let (fields, _) = wit_hook_result_to_native_filtered(result, ®istry, &ext, Some(&filtered)); + + let owned = fields + .modified_extensions + .expect("extensions should be present"); + + // HTTP must be preserved (not nulled) because the guest never saw it + assert!( + owned.http.is_some(), + "HTTP slot was nulled even though guest never received it" + ); + let http = owned.http.as_ref().unwrap().read(); + assert_eq!( + http.request_headers + .get("Authorization") + .map(|s| s.as_str()), + Some("Bearer token"), + "HTTP headers lost during writeback" + ); + + // Labels should reflect the guest's modification + let sec = owned.security.as_ref().unwrap(); + assert!(sec.has_label("NEW_LABEL")); + assert!(sec.has_label("PII")); +} + +#[test] +fn test_hidden_delegation_preserved_when_plugin_modifies_http() { + use cpex_core::extensions::filter_extensions; + use cpex_wasm_host::conversions::{ + native_extensions_to_wit, wit_hook_result_to_native_filtered, + }; + use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + + // Pipeline has delegation + let ext = build_full_extensions(); + assert!(ext.delegation.is_some()); + + // Plugin has HTTP caps but not delegation + let caps: HashSet = ["read_headers", "write_headers"] + .iter() + .map(|s| s.to_string()) + .collect(); + let filtered = filter_extensions(&ext, &caps); + assert!( + filtered.delegation.is_none(), + "delegation should be filtered" + ); + assert!(filtered.http.is_some(), "HTTP should be visible"); + + // Guest modifies HTTP and returns extensions + let mut wit_ext = native_extensions_to_wit(&filtered); + if let Some(ref mut h) = wit_ext.http { + h.request_headers + .push(("X-Added".to_string(), "value".to_string())); + } + + let result = cpex_wasm_host::sandbox_manager::types::HookResult { + continue_processing: true, + modified_payload: None, + modified_extensions: Some(wit_ext), + modified_context: None, + violation: None, + metadata: None, + }; + + let registry = PayloadSerializerRegistry::new(); + let (fields, _) = wit_hook_result_to_native_filtered(result, ®istry, &ext, Some(&filtered)); + + let owned = fields + .modified_extensions + .expect("extensions should be present"); + + // Delegation must be preserved + assert!( + owned.delegation.is_some(), + "Delegation slot nulled even though guest never received it" + ); +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +fn build_full_extensions() -> Extensions { + let security = SecurityExtension { + labels: MonotonicSet::from_set(["PII".to_string()].into_iter().collect()), + ..Default::default() + }; + + let mut http = HttpExtension::default(); + http.request_headers + .insert("Authorization".into(), "Bearer token".into()); + + 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())), + agent: Some(Arc::new(cpex_core::extensions::AgentExtension::default())), + ..Default::default() + } +} + +fn build_extensions_with_labels(labels: &[&str]) -> Extensions { + let security = SecurityExtension { + labels: MonotonicSet::from_set(labels.iter().map(|s| s.to_string()).collect()), + ..Default::default() + }; + + Extensions { + request: Some(Arc::new(RequestExtension { + request_id: Some("req-001".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(Arc::new(HttpExtension::default())), + delegation: Some(Arc::new(DelegationExtension::default())), + ..Default::default() + } +} diff --git a/crates/cpex-wasm-host/tests/test_token_delegation_e2e.rs b/crates/cpex-wasm-host/tests/test_token_delegation_e2e.rs new file mode 100644 index 00000000..671bdf7b --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_token_delegation_e2e.rs @@ -0,0 +1,195 @@ +// Location: ./crates/cpex-wasm-host/tests/test_token_delegation_e2e.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration test: verifies the token delegation hook (token.delegate) routes +//! through the WASM boundary end-to-end. +//! +//! The host sends a native DelegationPayload (HookPayload::Delegation variant), +//! the plugin macro routes it to the registered TokenDelegateHook handler, and +//! the minted token is returned via the pipeline result. +//! +//! Requires: token-attenuator.wasm built and staged. +//! Run `make build-all-plugins` from crates/cpex-wasm-host first. + +use std::path::PathBuf; +use std::sync::{Arc, Once}; + +use cpex_core::config::parse_config; +use cpex_core::delegation::{ + DelegationPayload, TargetType, TokenDelegateHook, HOOK_TOKEN_DELEGATE, +}; +use cpex_core::extensions::container::Extensions; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm") +} + +fn check_binary_exists() { + let path = wasm_dir().join("token-attenuator.wasm"); + assert!( + path.exists(), + "WASM binary not found: {}. Run `make build-all-plugins` first.", + path.display() + ); +} + +async fn setup_manager() -> PluginManager { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_token_attenuator_demo.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + let yaml = std::fs::read_to_string(&config_path).unwrap(); + let cpex_config = parse_config(&yaml).unwrap(); + + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://token-attenuator.wasm", + Box::new(WasmPluginFactory::new(wasm_dir, registry).expect("engine")), + ); + + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_token_delegation_mints_for_tool_target() { + init_tracing(); + check_binary_exists(); + let mgr = setup_manager().await; + + let payload = DelegationPayload::new("eyJ.caller-token", "get_compensation") + .with_target_type(TargetType::Tool) + .with_target_audience("https://hr-service.internal/api") + .with_required_permissions(vec!["read:compensation".into()]); + + let (result, bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + payload, + Extensions::default(), + None, + ) + .await; + bg.wait_for_background_tasks().await; + + let resolved = DelegationPayload::from_pipeline_result(&result) + .expect("pipeline should return a modified DelegationPayload"); + + let token = resolved + .delegated_token + .as_ref() + .expect("tool target should produce a minted token"); + + assert_eq!(token.audience, "https://hr-service.internal/api"); + assert_eq!(token.scopes, vec!["read:compensation"]); + assert_eq!(token.outbound_header, "Authorization"); + assert_eq!( + resolved.delegation_mode, + Some(cpex_core::extensions::raw_credentials::DelegationMode::OnBehalfOfUser) + ); + assert_eq!( + resolved.metadata.get("minter").and_then(|v| v.as_str()), + Some("token-attenuator-wasm") + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_token_delegation_passes_through_agent_target() { + init_tracing(); + check_binary_exists(); + let mgr = setup_manager().await; + + let payload = DelegationPayload::new("eyJ.caller-token", "summarizer-agent") + .with_target_type(TargetType::Agent) + .with_target_audience("https://agents.internal/summarizer"); + + let (result, bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + payload, + Extensions::default(), + None, + ) + .await; + bg.wait_for_background_tasks().await; + + assert!( + result.continue_processing, + "agent targets should be allowed through" + ); + // Agent targets should not produce a minted token + let resolved = DelegationPayload::from_pipeline_result(&result); + match resolved { + Some(p) => assert!( + p.delegated_token.is_none(), + "agent target should not mint a token" + ), + None => {} // no modified payload means pass-through — also valid + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_token_delegation_multi_permission_scopes() { + init_tracing(); + check_binary_exists(); + let mgr = setup_manager().await; + + let payload = DelegationPayload::new("eyJ.caller-token", "query_external_data") + .with_target_type(TargetType::Tool) + .with_target_audience("https://data-svc.internal/query") + .with_required_permissions(vec![ + "read:records".into(), + "read:metadata".into(), + "read:audit".into(), + ]); + + let (result, bg) = mgr + .invoke_named::( + HOOK_TOKEN_DELEGATE, + payload, + Extensions::default(), + None, + ) + .await; + bg.wait_for_background_tasks().await; + + let resolved = DelegationPayload::from_pipeline_result(&result) + .expect("pipeline should return a modified DelegationPayload"); + + let token = resolved + .delegated_token + .as_ref() + .expect("tool target should produce a minted token"); + + assert_eq!(token.audience, "https://data-svc.internal/query"); + assert_eq!( + token.scopes, + vec!["read:records", "read:metadata", "read:audit"] + ); +} diff --git a/crates/cpex-wasm-host/wit/deps/cli.wit b/crates/cpex-wasm-host/wit/deps/cli.wit new file mode 100644 index 00000000..d7a3ca4d --- /dev/null +++ b/crates/cpex-wasm-host/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-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..528f9188 --- /dev/null +++ b/crates/cpex-wasm-host/wit/world.wit @@ -0,0 +1,678 @@ +// 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. +// +// Supports arbitrary payload types via the hook-payload variant: +// - cmf: structured CMF MessagePayload (field-by-field conversion, no full-payload serialization) +// - custom: any serializable payload as JSON bytes with a type discriminator + +package cpex:plugin; + +interface types { + + // --------------------------------------------------------------------------- + // CMF enums: + // --------------------------------------------------------------------------- + + // Verified + enum role { + system, + developer, + user, + assistant, + tool, + } + + // Verified + enum channel { + analysis, + commentary, + final, + } + + // Verified + enum resource-type { + file, + blob, + uri, + database, + api, + memory, + artifact, + } + + // --------------------------------------------------------------------------- + // CMF content parts + // --------------------------------------------------------------------------- + // Verified + record image-source { + source-type: string, + data: string, + media-type: option, + } + + // Verified + record video-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + // Verified + record audio-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + // Verified + record document-source { + source-type: string, + data: string, + media-type: option, + title: option, + } + + // Verified + record tool-call { + tool-call-id: string, + name: string, + // arguments is a JSON object string (HashMap) + arguments: string, + namespace: option, + } + + // Verified + record tool-result { + tool-call-id: string, + tool-name: string, + // content is a JSON string (serde_json::Value) + content: string, + is-error: bool, + } + + // Verified + 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 is a JSON object string (HashMap) + annotations: string, + version: option, + } + + // Verified + record resource-reference { + resource-request-id: string, + uri: string, + name: option, + resource-type: resource-type, + range-start: option, + range-end: option, + selector: option, + } + + // Verified + record prompt-request { + prompt-request-id: string, + name: string, + // arguments is a JSON object string (HashMap) + arguments: string, + server-id: option, + } + + // Verified + record prompt-result { + prompt-request-id: string, + prompt-name: string, + // messages is a JSON array string (Vec) — cannot be list + // because message → content-part → prompt-result is a recursive cycle, + // which WIT does not support without indirection. + messages: string, + content: option, + is-error: bool, + error-message: option, + } + + // Verified + 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), + } + + // Verified + record message { + schema-version: string, + role: role, + content: list, + channel: option, + } + + // Verified + record message-payload { + message: message, + } + + // --------------------------------------------------------------------------- + // Custom payload for arbitrary types + // --------------------------------------------------------------------------- + + record custom-payload { + payload-type: string, + payload-data: list, + } + + // --------------------------------------------------------------------------- + // Identity payload (IdentityResolve hook) + // Note: raw_token is #[serde(skip)] in Rust — never crosses the WASM + // boundary. WASM handlers resolve identity from source/headers/claims. + // --------------------------------------------------------------------------- + + // Verified + enum token-source { + bearer, + user-token, + mtls, + spiffe-jwt-svid, + api-key, + // custom source name carried as a string alongside this enum value + // via identity-payload.source-custom when this variant is selected + custom, + } + + // Verified + record identity-payload { + // Input fields (host-supplied, read-only for handlers) + source: token-source, + // non-empty when source = custom + source-custom: option, + source-header: option, + // request headers as key-value pairs — escape hatch for custom auth flows + headers: list>, + client-host: option, + client-port: option, + + // Output fields (handlers populate these on the returned payload) + subject: option, + client: option, + caller-workload: option, + // initial delegation chain parsed from act/equivalent claims + delegation: option, + // resolved_at as ISO 8601 string (DateTime) + resolved-at: option, + // raw decoded token claims as a JSON object string (HashMap) + raw-claims: option, + } + + // --------------------------------------------------------------------------- + // Delegation payload (TokenDelegate hook) + // Note: bearer_token and delegated_token.token are #[serde(skip)] in Rust — + // they never cross the WASM boundary. WASM handlers can attenuat scopes and + // populate delegation_update/metadata but cannot mint raw tokens. + // --------------------------------------------------------------------------- + + enum target-type { + tool, + agent, + %resource, + service, + // custom target kind name carried in delegation-payload.target-type-custom + custom, + } + + enum auth-enforced-by { + caller, + target, + both, + } + + enum delegation-mode { + on-behalf-of-user, + as-gateway, + } + + record attenuation-config { + capabilities: list, + resource-template: option, + actions: list, + ttl-seconds: option, + } + + // Minted outbound credential (token bytes are #[serde(skip)] — omitted). + record raw-delegated-token { + outbound-header: string, + audience: string, + scopes: list, + // expires_at as ISO 8601 string (DateTime) + expires-at: string, + } + + record delegation-payload { + // Input fields (host-supplied, read-only for handlers) + target-name: string, + target-type: target-type, + // non-empty when target-type = custom + target-type-custom: option, + target-audience: option, + required-permissions: list, + trust-domain: option, + auth-enforced-by: auth-enforced-by, + route-attenuation: option, + + // Output fields (handlers populate these on the returned payload) + // token bytes omitted — raw credential material never crosses the sandbox + delegated-token: option, + delegation-update: option, + delegation-mode: option, + // minted_at as ISO 8601 string (DateTime) + minted-at: option, + // handler metadata as a JSON object string (HashMap) + metadata: option, + } + + variant hook-payload { + cmf(message-payload), + identity(identity-payload), + delegation(delegation-payload), + custom(custom-payload), + } + + // --------------------------------------------------------------------------- + // Plugin context + // --------------------------------------------------------------------------- + + // State entries are typed key-value pairs (serde_json::Value serialized per + // entry as a JSON string) rather than a single opaque JSON blob. + record context-entry { + key: string, + // value is a JSON string representing serde_json::Value + value: string, + } + + record plugin-context { + local-state: list, + global-state: list, + } + + // --------------------------------------------------------------------------- + // Extensions — base four + // --------------------------------------------------------------------------- + + record request-extension { + environment: option, + request-id: option, + timestamp: option, + trace-id: option, + span-id: option, + } + + enum subject-type { + user, + agent, + service, + system, + } + + record subject-extension { + id: option, + subject-type: option, + roles: list, + permissions: list, + teams: list, + claims: list>, + } + + // --------------------------------------------------------------------------- + // Security extension — full coverage + // --------------------------------------------------------------------------- + + enum client-trust-level { + first-party, + third-party, + internal, + // custom trust level — name carried in client-trust-level-custom on + // client-extension when this variant is not sufficient + } + + record client-extension { + client-id: string, + client-name: option, + trust-level: client-trust-level, + // custom trust level name when trust-level cannot represent it + trust-level-custom: option, + authorized-scopes: list, + authorized-audiences: list, + roles: list, + permissions: list, + teams: list, + // claims values are JSON strings (serde_json::Value per entry) + claims: list>, + } + + record workload-identity { + spiffe-id: option, + trust-domain: option, + // attested-at as ISO 8601 string (DateTime) + attested-at: option, + attestor: option, + selectors: list, + client-id: option, + } + + record object-security-profile { + managed-by: option, + permissions: list, + trust-domain: option, + data-scope: list, + } + + record retention-policy { + max-age-seconds: option, + policy: string, + delete-after: option, + } + + record data-policy { + apply-labels: list, + // None = all actions allowed; Some([]) = no actions allowed + allowed-actions: option>, + denied-actions: list, + retention: option, + } + + record security-extension { + labels: list, + classification: option, + subject: option, + client: option, + caller-workload: option, + this-workload: option, + auth-method: option, + // object security profiles keyed by object name + objects: list>, + // data policies keyed by data element name + data: list>, + } + + record http-extension { + request-headers: list>, + response-headers: list>, + method: option, + path: option, + host: option, + scheme: option, + } + + record meta-extension { + entity-type: option, + entity-name: option, + tags: list, + scope: option, + properties: list>, + } + + // --------------------------------------------------------------------------- + // Overflow extension types — typed + // --------------------------------------------------------------------------- + + record conversation-context { + // history entries are JSON strings (serde_json::Value per entry) + history: list, + summary: option, + topics: list, + } + + record agent-extension { + input: option, + session-id: option, + conversation-id: option, + turn: option, + agent-id: option, + parent-agent-id: option, + conversation: option, + } + + record tool-metadata { + name: string, + title: option, + description: option, + // input-schema and output-schema are JSON strings (serde_json::Value) + input-schema: option, + output-schema: option, + server-id: option, + namespace: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record resource-metadata { + uri: string, + name: option, + description: option, + mime-type: option, + server-id: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record prompt-metadata { + name: string, + description: option, + // arguments is a JSON array string (Vec) + arguments: option, + server-id: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record mcp-extension { + tool: option, + // renamed from `resource` to avoid WIT keyword conflict + resource-info: option, + prompt: option, + } + + // stop-reason: `return-complete` maps to Rust `StopReason::Return` + // (renamed to avoid the WIT `return` keyword) + enum stop-reason { + end, + return-complete, + call, + max-tokens, + stop-sequence, + } + + record token-usage { + input-tokens: u32, + output-tokens: u32, + total-tokens: u32, + } + + record completion-extension { + stop-reason: option, + tokens: option, + model: option, + raw-format: option, + created-at: option, + latency-ms: option, + } + + record provenance-extension { + source: option, + message-id: option, + parent-id: option, + } + + record llm-extension { + model-id: option, + provider: option, + capabilities: list, + } + + record framework-extension { + framework: option, + framework-version: option, + node-id: option, + graph-id: option, + // metadata is a JSON object string (HashMap) + metadata: option, + } + + record authorization-detail { + detail-type: string, + // option preserves None (no constraint) vs Some([]) (empty = deny all) + locations: option>, + actions: option>, + datatypes: option>, + identifier: option, + privileges: option>, + // extra holds flattened RFC 9396 extension fields as a JSON object string + extra: option, + } + + enum delegation-strategy { + token-exchange, + client-credentials, + spiffe-svid, + passthrough, + ucan, + transaction-token, + } + + record delegation-hop { + subject-id: string, + subject-type: option, + audience: option, + scopes-granted: list, + authorization-details: list, + // timestamp as ISO 8601 string (DateTime) + timestamp: string, + ttl-seconds: option, + strategy: option, + // carries the name when Rust DelegationStrategy::Custom(s) is used + strategy-custom: option, + from-cache: bool, + } + + record delegation-extension { + chain: list, + depth: u32, + origin-subject-id: option, + actor-subject-id: option, + delegated: bool, + // age-seconds as string to avoid f64 portability issues + age-seconds: string, + } + + // --------------------------------------------------------------------------- + // Extensions container + // --------------------------------------------------------------------------- + + record extensions { + request: option, + security: option, + http: option, + meta: option, + agent: option, + mcp: option, + completion: option, + provenance: option, + llm: option, + framework: option, + delegation: option, + // custom: escape hatch for plugin-defined arbitrary key-value data + // (maps to cpex-core Extensions.custom: Option>) + custom: option, + } + + // --------------------------------------------------------------------------- + // Violation and result + // --------------------------------------------------------------------------- + + record plugin-violation { + code: string, + reason: string, + description: option, + // details is a JSON object string (HashMap) + details: string, + plugin-name: option, + proto-error-code: option, + } + + // Verified + // metadata is a JSON string (serde_json::Value) + record hook-result { + continue-processing: bool, + modified-payload: option, + modified-extensions: option, + modified-context: option, + violation: option, + metadata: option, + } +} + +interface host-logging { + enum log-level { + trace, + debug, + info, + warn, + error, + } + + log: func(level: log-level, message: string); +} + +/// Known hook names (not exhaustive - hosts may define custom hooks): +/// +/// Legacy (typed payloads): +/// "tool_pre_invoke", "tool_post_invoke" +/// "prompt_pre_fetch", "prompt_post_fetch" +/// "resource_pre_fetch", "resource_post_fetch" +/// "identity_resolve", "token_delegate" +/// +/// CMF (MessagePayload): +/// "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" +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; + import host-logging; + + use types.{hook-payload, extensions, plugin-context, hook-result}; + + export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context + ) -> hook-result; +} diff --git a/crates/cpex-wasm-plugin/.gitignore b/crates/cpex-wasm-plugin/.gitignore new file mode 100644 index 00000000..66e0fb16 --- /dev/null +++ b/crates/cpex-wasm-plugin/.gitignore @@ -0,0 +1,2 @@ +/target +/wasm diff --git a/crates/cpex-wasm-plugin/Cargo.toml b/crates/cpex-wasm-plugin/Cargo.toml new file mode 100644 index 00000000..10f55773 --- /dev/null +++ b/crates/cpex-wasm-plugin/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "cpex-wasm-plugin" +version = "0.1.0" +edition = "2021" +description = "WASM plugin guest SDK for the CPEX framework — register_wasm_plugin! macro, prelude, and reference examples" +publish = false +license = "Apache-2.0" +authors = ["Shriti Priya"] +repository = "https://github.com/contextforge-org/cpex" +homepage = "https://github.com/contextforge-org/cpex" +keywords = ["wasm", "plugin", "sdk", "wasi", "cpex"] +categories = ["wasm", "development-tools"] +rust-version = "1.78" + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +default = [] +# ── Demo feature flags (internal) ────────────────────────────────────────── +# These enable individual demo plugins for compilation. You don't need to +# touch these — use `make build-demo DEMO=` instead. +identity-checker = [] +header-injector = [] +audit-logger = [] +token-attenuator = [] +noop = [] +fs-test = [] +net-test = [] +env-test = [] +tool-invoke-checker = [] +pii-guard = [] +audit-logger-custom = [] +remote-authz = [] +compute-bench = [] +fs-sandbox-demo = [] +env-sandbox-demo = [] +resource-test = [] +net-http-test = [] +# Enables all demos for testing — use: cargo test --features test-demos +test-demos = [ + "identity-checker", "header-injector", "audit-logger", "token-attenuator", + "noop", "fs-test", "net-test", "env-test", "tool-invoke-checker", + "pii-guard", "audit-logger-custom", "remote-authz", "compute-bench", + "fs-sandbox-demo", "env-sandbox-demo", "resource-test", "net-http-test", +] + + +[dependencies] +wit-bindgen = "0.57" +wit-bindgen-rt = "0.44" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = "1" +cpex-core = { path = "../cpex-core", default-features = false } +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +zeroize = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } +tempfile = "3" + +[package.metadata.component] +package = "cpex:plugin" + +[package.metadata.cargo-machete] +ignored = ["wit-bindgen-rt"] diff --git a/crates/cpex-wasm-plugin/Makefile b/crates/cpex-wasm-plugin/Makefile new file mode 100644 index 00000000..f52be845 --- /dev/null +++ b/crates/cpex-wasm-plugin/Makefile @@ -0,0 +1,171 @@ +.PHONY: build build-debug build-demo build-demos validate validate-demos \ + inspect inspect-demo inspect-demos check check-demos test test-demos \ + test-all fmt fmt-check clippy clippy-demos ci clean help + +TARGET := wasm32-wasip2 +TARGET_DIR := target +PLUGIN_NAME := cpex_wasm_plugin + +# Default profile — override with PROFILE=debug +PROFILE := release +ARTIFACT = $(TARGET_DIR)/$(TARGET)/$(PROFILE)/$(PLUGIN_NAME).wasm + +# Demo plugin list — one binary is compiled per feature flag. +DEMOS := identity-checker header-injector audit-logger token-attenuator \ + tool-invoke-checker pii-guard audit-logger-custom remote-authz \ + noop fs-test net-test env-test compute-bench fs-sandbox-demo \ + env-sandbox-demo resource-test net-http-test + +# --------------------------------------------------------------------------- +# Build your plugin +# --------------------------------------------------------------------------- + +# build: optimized + smaller binary (~3s) — use for deployment +# build-debug: unoptimized + faster compile (~1-2s) — use for iteration + +build: ## Build your plugin (release, optimized — for deployment) + cargo build --target $(TARGET) --release + @echo "Built: $(ARTIFACT) ($$(du -h $(ARTIFACT) | cut -f1))" + +build-debug: ## Build your plugin (debug, fast compile — for iteration) + cargo build --target $(TARGET) + @echo "Built: $(TARGET_DIR)/$(TARGET)/debug/$(PLUGIN_NAME).wasm" + +# --------------------------------------------------------------------------- +# Build demos (src/examples/) +# --------------------------------------------------------------------------- + +build-demo: ## Build a single demo: make build-demo DEMO=noop +ifndef DEMO + $(error Usage: make build-demo DEMO= (available: $(DEMOS))) +endif + cargo build --target $(TARGET) --release --features $(DEMO) --no-default-features + @mkdir -p wasm + @cp $(ARTIFACT) wasm/$(DEMO).wasm + @echo "Built: wasm/$(DEMO).wasm ($$(du -h wasm/$(DEMO).wasm | cut -f1))" + +build-demos: ## Build all demo plugins to wasm/ directory + @mkdir -p wasm + @for demo in $(DEMOS); do \ + echo "Building $$demo..."; \ + cargo build --target $(TARGET) --release --features $$demo --no-default-features && \ + cp $(ARTIFACT) wasm/$$demo.wasm && \ + echo " wasm/$$demo.wasm ($$(du -h wasm/$$demo.wasm | cut -f1))"; \ + done + @echo "All demos built. Output: wasm/" + +# --------------------------------------------------------------------------- +# Validation and inspection (requires: cargo install wasm-tools) +# --------------------------------------------------------------------------- + +validate: ## Validate the built .wasm component binary + wasm-tools validate $(ARTIFACT) + @echo "$(ARTIFACT) is valid" + +validate-demos: ## Validate all demo .wasm files in wasm/ directory + @if [ ! -d wasm ]; then \ + echo "No wasm/ directory — run 'make build-demos' first"; exit 1; \ + fi + @for f in wasm/*.wasm; do \ + wasm-tools validate "$$f" && echo " $$f valid"; \ + done + @echo "All demos valid." + +inspect: ## Print the WIT interface embedded in your plugin binary + wasm-tools component wit $(ARTIFACT) + +inspect-demo: ## Print the WIT interface of a demo: make inspect-demo DEMO=noop +ifndef DEMO + $(error Usage: make inspect-demo DEMO=) +endif + @if [ ! -f wasm/$(DEMO).wasm ]; then \ + echo "wasm/$(DEMO).wasm not found — run 'make build-demo DEMO=$(DEMO)' first"; exit 1; \ + fi + wasm-tools component wit wasm/$(DEMO).wasm + +inspect-demos: ## Print the WIT interface of all demo .wasm files + @if [ ! -d wasm ]; then \ + echo "No wasm/ directory — run 'make build-demos' first"; exit 1; \ + fi + @for f in wasm/*.wasm; do \ + echo "── $$(basename $$f) ──"; \ + wasm-tools component wit "$$f"; \ + echo ""; \ + done + +# --------------------------------------------------------------------------- +# Testing +# --------------------------------------------------------------------------- + +test: ## Run tests for your plugin (src/plugin.rs) + cargo test --lib plugin::tests + +test-demos: ## Run inline tests for all demo plugins (src/examples/) + cargo test --features test-demos --lib + +test-all: ## Run all tests (plugin + demos + conversions + integration) + cargo test --features test-demos + +# --------------------------------------------------------------------------- +# Code quality +# --------------------------------------------------------------------------- + +check: ## Type-check your plugin (fast feedback) + cargo check --target $(TARGET) + +check-demos: ## Type-check all demo plugins + @for demo in $(DEMOS); do \ + echo "Checking $$demo..."; \ + cargo check --target $(TARGET) --features $$demo --no-default-features || exit 1; \ + done + @echo "All demos type-check passed." + +fmt: ## Format source code + cargo fmt + +fmt-check: ## Check formatting without modifying files + cargo fmt -- --check + +clippy: ## Run clippy lints on your plugin + cargo clippy --target $(TARGET) -- -D warnings + +clippy-demos: ## Run clippy lints on all demo plugins + @for demo in $(DEMOS); do \ + echo "Clippy $$demo..."; \ + cargo clippy --target $(TARGET) --features $$demo --no-default-features -- -D warnings || exit 1; \ + done + @echo "All demos clippy passed." + +# --------------------------------------------------------------------------- +# CI (run everything: format check, lint, test, build, validate) +# --------------------------------------------------------------------------- + +ci: ## Run full CI pipeline (fmt check + clippy + test + build + validate) + @echo "── Format check ──" + cargo fmt -- --check + @echo "── Clippy ──" + cargo clippy --target $(TARGET) -- -D warnings + @echo "── Tests (all) ──" + cargo test --features test-demos + @echo "── Build (plugin) ──" + cargo build --target $(TARGET) --release + @echo "── Validate ──" + wasm-tools validate $(ARTIFACT) + @echo "" + @echo "CI passed." + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +clean: ## Remove all build artifacts + cargo clean + rm -rf wasm + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' diff --git a/crates/cpex-wasm-plugin/README.md b/crates/cpex-wasm-plugin/README.md new file mode 100644 index 00000000..8392dad8 --- /dev/null +++ b/crates/cpex-wasm-plugin/README.md @@ -0,0 +1,402 @@ +# cpex-wasm-plugin + +WASM plugin SDK for the CPEX framework. Write a plugin in Rust, compile it to WebAssembly, and deploy it to the CPEX host — all without touching WIT files, serialization code, or WASM internals. + +## How WASM Plugins Work + +Plugins compile to [WebAssembly components](https://component-model.bytecodealliance.org/) targeting **WASI Preview 2** (`wasm32-wasip2`). The host loads each `.wasm` file, instantiates it in a sandboxed runtime (Wasmtime), and calls the exported `handle-hook` function whenever a matching hook fires. + +### The WASM Component Model + +This crate uses the [WASM Component Model](https://github.com/WebAssembly/component-model) — not raw WASM modules. Components define typed interfaces via **WIT (WebAssembly Interface Types)**: + +- **Exports** — functions the host calls into your plugin (`handle-hook`) +- **Imports** — functions your plugin can call on the host (`host-logging`, WASI APIs) +- **Types** — structured records/enums that cross the boundary (`HookPayload`, `HookResult`, `Extensions`) + +The WIT definition lives in `wit/world.wit`. You never edit it — the SDK handles everything. + +### The WIT → Rust Bridge + +`wit_bindgen::generate!` (in `src/lib.rs`) reads `wit/world.wit` at compile time and generates: +- A `Guest` trait with `handle_hook(...)` — the function signature the host expects +- An `export!` macro — produces the `#[no_mangle]` ABI entry points +- Rust structs for all WIT types (flat/serialized forms, not the rich cpex-core types) + +The `register_wasm_plugin!` macro generates the `Guest` impl that: +1. Receives flat WIT types from the host +2. Converts them to cpex-core native Rust types (via `conversions.rs`) +3. Calls your `HookHandler::handle()` method +4. Converts your `PluginResult` back to a WIT `HookResult` +5. Returns it to the host + +### Build Target: `wasm32-wasip2` + +Plugins compile to `wasm32-wasip2` — WebAssembly with WASI Preview 2 support. This provides: +- **Sandboxed I/O** — filesystem, network, env access are capability-gated by the host +- **Component model ABI** — typed function signatures, not raw memory manipulation +- **Outbound HTTP** (optional) — `wasi:http/outgoing-handler` for plugins that need network access + +The output `.wasm` is a self-contained component binary (~660KB–900KB) with no external dependencies. + +### What Happens at Runtime + +``` +Host receives a hook event (e.g., tool_pre_invoke) + → Looks up plugins registered for that hook (from config.yaml) + → For each plugin (in priority order): + → Serializes payload/extensions/context to WIT types + → Calls handle-hook on the WASM instance + → Plugin runs in sandbox (memory-limited, fuel-limited, time-limited) + → Reads the HookResult: allow / deny / modify + → If denied: pipeline stops, violation returned to caller + → If modified: updated payload passes to next plugin + → If allowed: unchanged payload passes to next plugin +``` + +## Prerequisites + +```bash +rustup target add wasm32-wasip2 +cargo install wasm-tools # optional, for validation +``` + +## Quick Start + +```bash +# 1. Edit your plugin logic +vim src/plugin.rs + +# 2. Build to WASM +make build + +# 3. Deploy — copy to host +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm /path/to/cpex-wasm-host/wasm/my-plugin.wasm +``` + +## Writing Your Plugin + +Open `src/plugin.rs`. It contains a ready-to-compile template: + +```rust +use crate::prelude::*; +use std::sync::OnceLock; + +static PLUGIN_CONFIG: OnceLock = OnceLock::new(); + +#[derive(Default)] +pub struct UserPlugin; + +#[async_trait] +impl Plugin for UserPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "my-plugin".to_string(), + kind: "wasm://my-plugin.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + async fn initialize(&self) -> Result<(), Box> { Ok(()) } + async fn shutdown(&self) -> Result<(), Box> { Ok(()) } +} + +impl HookHandler for UserPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Your logic here + PluginResult::allow() + } +} +``` + +### What to return from `handle()` + +| Return value | Effect | +|---|---| +| `PluginResult::allow()` | Pass through unchanged | +| `PluginResult::deny(violation)` | Block the request with a reason | +| `PluginResult::modify_payload(p)` | Pass through with a modified payload | + +### Creating a violation + +```rust +PluginResult::deny(PluginViolation::new( + "my_error_code", + "Human-readable reason why this was blocked", +)) +``` + +## Hook Types + +Your plugin intercepts a specific type of event. Choose the hook that matches your use case: + +| Hook type | Payload | Event name | Use case | +|---|---|---|---| +| `CmfHook` | `MessagePayload` | `cmf.tool_pre_invoke` | Intercept tool calls / results | +| `IdentityHook` | `IdentityPayload` | `identity.resolve` | Resolve user identity from headers/tokens | +| `TokenDelegateHook` | `DelegationPayload` | `token.delegate` | Mint scoped outbound credentials | +| Custom | Your struct | Your event name | Any domain-specific hook | + +To use a different hook type, change both: +1. The `impl HookHandler<...>` in `src/plugin.rs` +2. The `register_wasm_plugin!` call at the bottom of `src/lib.rs` + +## Custom Hook Types + +For domain-specific hooks, define your own payload and hook type in `src/plugin.rs`: + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MyPayload { + pub action: String, + pub subject: String, +} + +impl_plugin_payload!(MyPayload); +impl_wasm_payload!(MyPayload, "my_namespace.pre_invoke"); + +define_hook! { MyHook, "my_namespace.pre_invoke" => { + payload: MyPayload, + result: PluginResult, +}} +``` + +Then implement `HookHandler` and update `lib.rs` registration to use `plugin::MyHook`. + +See `src/examples/tool_invoke_checker.rs` for a complete working example. + +## Testing + +### Test your plugin + +Add tests at the bottom of `src/plugin.rs` (same pattern as the demo plugins): + +```rust +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; + use cpex_core::cmf::constants::SCHEMA_VERSION; + + #[tokio::test] + async fn test_allows_request() { + let payload = MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_1".into(), + name: "my_tool".into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &UserPlugin, &payload, &ext, &mut ctx, + ).await; + + assert!(result.continue_processing); + } +} +``` + +Run: +```bash +make test # your plugin tests only +cargo test # all tests +``` + +### Test demo plugins + +```bash +make test-demos # runs inline tests for all 17 demo plugins +``` + +## Logging + +Use `cpex_log!` for structured logging to the host's tracing infrastructure: + +```rust +cpex_log!(info, "processing tool call '{}'", tool_name); +cpex_log!(warn, "missing required field"); +cpex_log!(error, "unexpected payload format"); +``` + +Available levels: `trace`, `debug`, `info`, `warn`, `error`. + +## Deploying to the Host + +After `make build`, configure the host to load your plugin: + +```yaml +# In cpex-wasm-host config.yaml +plugins: + - name: my-plugin + kind: wasm://my-plugin.wasm + hooks: [cmf.tool_pre_invoke] + priority: 50 + capabilities: [read_labels, read_subject] + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 +``` + +### Sandbox capabilities + +Plugins run in a sandboxed WASM environment. By default, they have NO access to: +- Host filesystem +- Outbound network +- Environment variables + +Grant access explicitly in `sandbox_policy` if your plugin needs it. + +### Plugin capabilities (what data your plugin can see) + +| Capability | What it grants | +|---|---| +| `read_labels` | See security labels on the request | +| `read_subject` | See the authenticated user identity | +| `read_roles` | See the user's role assignments | +| `read_inbound_credentials` | See raw auth tokens (sensitive) | + +## Project Structure + +``` +cpex-wasm-plugin/ + src/ + lib.rs # SDK internals (don't edit) + plugin.rs # YOUR PLUGIN — edit this + conversions.rs # WIT ↔ native type bridging + examples/ # 17 demo plugins for reference + wit/ + world.wit # WIT world definition (typed WASM boundary) + deps/ # WASI interface definitions (io, http, clocks, etc.) + Makefile # Build targets +``` + +## Makefile Targets + +| Target | Description | +|---|---| +| `make build` | Build your plugin to WASM (release, optimized) | +| `make build-debug` | Build (debug, faster compile — for iteration) | +| `make build-demo DEMO=noop` | Build a specific demo to `wasm/` | +| `make build-demos` | Build all demos to `wasm/` | +| `make test` | Run your plugin's tests | +| `make test-demos` | Run all demo inline tests | +| `make test-all` | Run everything (plugin + demos + conversions + doc-tests) | +| `make check` | Type-check your plugin (fast feedback) | +| `make check-demos` | Type-check all demos | +| `make validate` | Validate your .wasm with wasm-tools | +| `make validate-demos` | Validate all demo .wasm files | +| `make inspect` | Print the WIT interface in your binary | +| `make fmt` | Format source code | +| `make fmt-check` | Check formatting without modifying | +| `make clippy` | Run lints on your plugin | +| `make clippy-demos` | Run lints on all demos | +| `make ci` | Full CI pipeline (fmt + clippy + test + build + validate) | +| `make clean` | Remove all build artifacts | +| `make help` | Show all targets | + +## Demo Plugins + +Browse `src/examples/` for working reference implementations: + +| Demo | Hook | What it demonstrates | +|---|---|---| +| `noop.rs` | CmfHook | Minimal pass-through (good starting point) | +| `audit_logger.rs` | CmfHook | Log all hook invocations | +| `header_injector.rs` | CmfHook | Inject headers + security labels | +| `identity_checker.rs` | CmfHook + IdentityHook | Validate identity claims | +| `pii_guard.rs` | Custom ToolPreInvoke | Detect and block PII | +| `tool_invoke_checker.rs` | Custom ToolPreInvoke/PostInvoke | Enforce tool-call policies | +| `audit_logger_custom.rs` | Custom ToolPreInvoke/PostInvoke | Audit with custom payloads | +| `remote_authz.rs` | Custom ToolPreInvoke | Delegate to external authz | +| `token_attenuator.rs` | TokenDelegateHook | Downscope outbound tokens | +| `fs_test.rs` | CmfHook | Filesystem sandbox testing | +| `net_test.rs` | CmfHook | Network sandbox testing | +| `env_test.rs` | CmfHook | Environment variable access | +| `compute_bench.rs` | CmfHook | CPU computation benchmark | +| `fs_sandbox_demo.rs` | CmfHook | Filesystem sandbox operations | +| `env_sandbox_demo.rs` | CmfHook | Env var sandbox operations | +| `resource_test.rs` | CmfHook | Resource limit testing | +| `net_http_test.rs` | CmfHook | Outbound HTTP requests | + +## Advantages + +### Security +- **Full sandbox isolation** — each plugin runs in its own WASM sandbox; can't read your filesystem, exfiltrate data, access other plugins' memory, or crash the host +- **Capability-gated data access** — plugins only see the `Extensions` fields their declared capabilities allow +- **Enforced resource limits** — memory caps, fuel limits (instruction count), and execution timeouts prevent runaway plugins + +### Developer Experience +- **No WIT/WASM knowledge needed** — edit `src/plugin.rs`, implement `handle()`, run `make build` +- **Same trait as native plugins** — `HookHandler` is identical; prototype natively, deploy as WASM +- **Fast test feedback** — `make test` runs in under a second on your native machine +- **Rich typed boundary** — work with idiomatic Rust types (HashMap, enums, Arc), not raw bytes or JSON + +### Operational +- **Language-agnostic deployment** — `.wasm` is a standard component; the host doesn't care what produced it +- **Deterministic builds** — same source → same `.wasm`; no dynamic linking or platform-specific behavior +- **Plugin isolation without process overhead** — isolated like a separate process but with microsecond instantiation, not IPC +- **Composable hook pipeline** — multiple plugins chain on the same hook via priority ordering +- **Hot-swappable** — drop a new `.wasm` and restart the host; no host recompilation needed + +### Compared to Alternatives + +| vs. | Advantage of WASM plugins | +|---|---| +| Native dynamic libraries (.so/.dylib) | Sandboxed, portable, no ABI compatibility issues | +| gRPC/HTTP sidecar plugins | No network latency, no separate process to manage | +| Embedded scripting (Lua, JS) | Full Rust type safety, compiled performance, IDE support | +| Hardcoded in-process plugins | Isolated, independently deployable, can't crash the host | + +## Limitations + +### Architectural +- **One plugin per `.wasm` binary** — WIT component model exports a single `handle-hook` function +- **No async runtime** — `tokio`, `reqwest`, `hyper` don't work inside WASM; handlers complete synchronously +- **No cross-invocation state on the struct** — plugin is re-created via `Default::default()` every call; use `static OnceLock` for persistent data +- **Sandbox restrictions are absolute** — `std::fs::read()` compiles but traps at runtime unless the host config grants filesystem access + +### SDK-Specific +- **Plugins must live inside this crate** — `export!` is crate-local; external crates can't produce their own `.wasm` without a full SDK/template split (not yet implemented) +- **Feature flags for demo selection** — the Makefile hides them, but enabling two demo features simultaneously is a compile error +- **No hot-reload** — changing logic requires rebuild + redeploy; the host loads `.wasm` at startup + +### Testing +- **Tests run on native, not in WASM** — `make test` doesn't test the actual WIT serialization boundary or sandbox enforcement; end-to-end testing requires the host +- **Serialization cost at the boundary** — every `handle()` call deserializes the full payload from WIT types and serializes the result back; large payloads have overhead that native plugins don't pay + +## End-to-End Walkthrough + +1. **Clone the repo** and navigate to this crate +2. **Install the target**: `rustup target add wasm32-wasip2` +3. **Edit** `src/plugin.rs` — rename the struct, pick your hook, write your logic +4. **Update** `src/lib.rs` bottom — change the hook type in `register_wasm_plugin!` if you're not using `CmfHook` +5. **Test**: `make test` +6. **Build**: `make build` +7. **Validate** (optional): `make validate` +8. **Deploy**: copy the `.wasm` to the host and add a `config.yaml` entry +9. **Run the host** — your plugin intercepts matching hook invocations diff --git a/crates/cpex-wasm-plugin/src/conversions.rs b/crates/cpex-wasm-plugin/src/conversions.rs new file mode 100644 index 00000000..5d10c711 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/conversions.rs @@ -0,0 +1,1891 @@ +// Location: ./crates/cpex-wasm-plugin/src/conversions.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Bidirectional type conversions between WIT-generated types and cpex-core native types. +// WIT types are flat/serialized (e.g., JSON strings for maps); native types use +// proper Rust collections (HashMap, HashSet, Vec). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use chrono::DateTime; + +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::delegation::{ + AttenuationConfig as NativeAttenuationConfig, AuthEnforcedBy as NativeAuthEnforcedBy, + DelegationPayload as NativeDelegationPayload, TargetType as NativeTargetType, +}; +use cpex_core::extensions::raw_credentials::{ + DelegationMode as NativeDelegationMode, RawDelegatedToken as NativeRawDelegatedToken, +}; +use cpex_core::extensions::agent::AgentExtension as NativeAgentExtension; +use cpex_core::extensions::authorization::AuthorizationDetail as NativeAuthDetail; +use cpex_core::extensions::completion::{ + CompletionExtension as NativeCompletionExtension, StopReason as NativeStopReason, + TokenUsage as NativeTokenUsage, +}; +use cpex_core::extensions::container::Extensions as NativeExtensions; +use cpex_core::extensions::delegation::{ + DelegationExtension as NativeDelegationExtension, DelegationHop as NativeDelegationHop, + DelegationStrategy as NativeDelegationStrategy, +}; +use cpex_core::extensions::framework::FrameworkExtension as NativeFrameworkExtension; +use cpex_core::extensions::http::HttpExtension as NativeHttpExtension; +use cpex_core::extensions::llm::LLMExtension as NativeLLMExtension; +use cpex_core::extensions::mcp::{ + MCPExtension as NativeMCPExtension, PromptMetadata as NativePromptMetadata, + ResourceMetadata as NativeResourceMetadata, ToolMetadata as NativeToolMetadata, +}; +use cpex_core::extensions::meta::MetaExtension as NativeMetaExtension; +use cpex_core::extensions::provenance::ProvenanceExtension as NativeProvenanceExtension; +use cpex_core::extensions::request::RequestExtension as NativeRequestExtension; +use cpex_core::extensions::security::{ + ClientExtension as NativeClientExtension, ClientTrustLevel as NativeClientTrustLevel, + DataPolicy as NativeDataPolicy, ObjectSecurityProfile as NativeObjectSecurityProfile, + RetentionPolicy as NativeRetentionPolicy, SecurityExtension as NativeSecurityExtension, + SubjectExtension as NativeSubjectExtension, SubjectType as NativeSubjectType, + WorkloadIdentity as NativeWorkloadIdentity, +}; +use cpex_core::hooks::trait_def::PluginResult as NativePluginResult; +use cpex_core::identity::{IdentityPayload as NativeIdentityPayload, TokenSource as NativeTokenSource}; + +use crate::cpex::plugin::types::*; + +// --------------------------------------------------------------------------- +// WIT → Native: MessagePayload +// --------------------------------------------------------------------------- + +/// Convert a WIT MessagePayload to a native cpex-core MessagePayload. +pub 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: native_content::ToolCall { + tool_call_id: tc.tool_call_id, + name: tc.name, + arguments: serde_json::from_str(&tc.arguments).unwrap_or_default(), + namespace: tc.namespace, + }, + }, + ContentPart::ToolResult(tr) => native_content::ContentPart::ToolResult { + content: native_content::ToolResult { + tool_call_id: tr.tool_call_id, + tool_name: tr.tool_name, + content: serde_json::from_str(&tr.content) + .unwrap_or(serde_json::Value::String(tr.content)), + is_error: tr.is_error, + }, + }, + ContentPart::CmfResource(r) => native_content::ContentPart::Resource { + content: 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: serde_json::from_str(&r.annotations).unwrap_or_default(), + version: r.version, + }, + }, + ContentPart::ResourceRef(rr) => native_content::ContentPart::ResourceRef { + content: 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, + }, + }, + ContentPart::PromptRequest(pr) => native_content::ContentPart::PromptRequest { + content: native_content::PromptRequest { + prompt_request_id: pr.prompt_request_id, + name: pr.name, + arguments: serde_json::from_str(&pr.arguments).unwrap_or_default(), + server_id: pr.server_id, + }, + }, + ContentPart::PromptResult(pr) => native_content::ContentPart::PromptResult { + content: native_content::PromptResult { + prompt_request_id: pr.prompt_request_id, + prompt_name: pr.prompt_name, + messages: serde_json::from_str(&pr.messages).unwrap_or_default(), + content: pr.content, + is_error: pr.is_error, + error_message: pr.error_message, + }, + }, + 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_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, + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: Extensions (full coverage) +// --------------------------------------------------------------------------- + +/// Convert WIT Extensions to native cpex-core Extensions (Arc-wrapped fields). +pub fn wit_extensions_to_native(ext: Extensions) -> NativeExtensions { + NativeExtensions { + request: ext.request.map(|r| Arc::new(NativeRequestExtension { + environment: r.environment, + request_id: r.request_id, + timestamp: r.timestamp, + trace_id: r.trace_id, + span_id: r.span_id, + })), + security: ext.security.map(|s| Arc::new(wit_security_to_native(s))), + http: ext.http.map(|h| Arc::new(NativeHttpExtension { + request_headers: h.request_headers.into_iter().collect(), + response_headers: h.response_headers.into_iter().collect(), + method: h.method, + path: h.path, + host: h.host, + scheme: h.scheme, + })), + meta: ext.meta.map(|m| Arc::new(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::>(), + })), + agent: ext.agent.map(|a| Arc::new(wit_agent_to_native(a))), + mcp: ext.mcp.map(|m| Arc::new(wit_mcp_to_native(m))), + completion: ext.completion.map(|c| Arc::new(wit_completion_to_native(c))), + provenance: ext.provenance.map(|p| Arc::new(NativeProvenanceExtension { + source: p.source, + message_id: p.message_id, + parent_id: p.parent_id, + })), + llm: ext.llm.map(|l| Arc::new(NativeLLMExtension { + model_id: l.model_id, + provider: l.provider, + capabilities: l.capabilities, + })), + framework: ext.framework.map(|f| Arc::new(wit_framework_to_native(f))), + delegation: ext.delegation.map(|d| Arc::new(wit_delegation_to_native(d))), + custom: ext.custom.and_then(|s| serde_json::from_str(&s).ok()).map(Arc::new), + ..Default::default() + } +} + +fn wit_security_to_native(s: SecurityExtension) -> NativeSecurityExtension { + NativeSecurityExtension { + labels: cpex_core::extensions::monotonic::MonotonicSet::from_set( + s.labels.into_iter().collect(), + ), + classification: s.classification, + subject: s.subject.map(|sub| NativeSubjectExtension { + id: sub.id, + subject_type: sub.subject_type.map(wit_subject_type_to_native), + roles: sub.roles.into_iter().collect(), + permissions: sub.permissions.into_iter().collect(), + teams: sub.teams.into_iter().collect(), + claims: sub.claims.into_iter().collect(), + }), + client: s.client.map(wit_client_to_native), + caller_workload: s.caller_workload.map(wit_workload_to_native), + this_workload: s.this_workload.map(wit_workload_to_native), + auth_method: s.auth_method, + objects: s.objects.into_iter() + .map(|(k, v)| (k, NativeObjectSecurityProfile { + managed_by: v.managed_by, + permissions: v.permissions, + trust_domain: v.trust_domain, + data_scope: v.data_scope, + })) + .collect(), + data: s.data.into_iter() + .map(|(k, v)| (k, NativeDataPolicy { + apply_labels: v.apply_labels, + allowed_actions: v.allowed_actions, + denied_actions: v.denied_actions, + retention: v.retention.map(|r| NativeRetentionPolicy { + max_age_seconds: r.max_age_seconds, + policy: r.policy, + delete_after: r.delete_after, + }), + })) + .collect(), + } +} + +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, + } +} + +fn wit_client_to_native(c: ClientExtension) -> NativeClientExtension { + let trust_level = match c.trust_level_custom { + Some(s) => NativeClientTrustLevel::Custom(s), + None => match c.trust_level { + ClientTrustLevel::FirstParty => NativeClientTrustLevel::FirstParty, + ClientTrustLevel::ThirdParty => NativeClientTrustLevel::ThirdParty, + ClientTrustLevel::Internal => NativeClientTrustLevel::Internal, + }, + }; + NativeClientExtension { + client_id: c.client_id, + client_name: c.client_name, + trust_level, + authorized_scopes: c.authorized_scopes, + authorized_audiences: c.authorized_audiences, + roles: c.roles, + permissions: c.permissions, + teams: c.teams, + claims: c.claims.into_iter() + .map(|(k, v)| (k, serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)))) + .collect(), + } +} + +fn wit_workload_to_native(w: WorkloadIdentity) -> NativeWorkloadIdentity { + NativeWorkloadIdentity { + spiffe_id: w.spiffe_id, + trust_domain: w.trust_domain, + attested_at: w.attested_at.as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)), + attestor: w.attestor, + selectors: w.selectors, + client_id: w.client_id, + } +} + +fn wit_agent_to_native(a: AgentExtension) -> NativeAgentExtension { + use cpex_core::extensions::agent::ConversationContext; + NativeAgentExtension { + input: a.input, + session_id: a.session_id, + conversation_id: a.conversation_id, + turn: a.turn, + agent_id: a.agent_id, + parent_agent_id: a.parent_agent_id, + conversation: a.conversation.map(|c| ConversationContext { + history: c.history.iter() + .map(|s| serde_json::from_str(s).unwrap_or(serde_json::Value::String(s.clone()))) + .collect(), + summary: c.summary, + topics: c.topics, + }), + } +} + +fn wit_mcp_to_native(m: McpExtension) -> NativeMCPExtension { + NativeMCPExtension { + tool: m.tool.map(|t| NativeToolMetadata { + name: t.name, + title: t.title, + description: t.description, + input_schema: t.input_schema.and_then(|s| serde_json::from_str(&s).ok()), + output_schema: t.output_schema.and_then(|s| serde_json::from_str(&s).ok()), + server_id: t.server_id, + namespace: t.namespace, + annotations: t.annotations.into_iter() + .map(|(k, v)| (k, serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)))) + .collect(), + }), + resource: m.resource_info.map(|r| NativeResourceMetadata { + uri: r.uri, + name: r.name, + description: r.description, + mime_type: r.mime_type, + server_id: r.server_id, + annotations: r.annotations.into_iter() + .map(|(k, v)| (k, serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)))) + .collect(), + }), + prompt: m.prompt.map(|p| NativePromptMetadata { + name: p.name, + description: p.description, + arguments: p.arguments.and_then(|s| serde_json::from_str(&s).ok()), + server_id: p.server_id, + annotations: p.annotations.into_iter() + .map(|(k, v)| (k, serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)))) + .collect(), + }), + } +} + +fn wit_completion_to_native(c: CompletionExtension) -> NativeCompletionExtension { + NativeCompletionExtension { + stop_reason: c.stop_reason.map(|r| match r { + StopReason::End => NativeStopReason::End, + StopReason::ReturnComplete => NativeStopReason::Return, + StopReason::Call => NativeStopReason::Call, + StopReason::MaxTokens => NativeStopReason::MaxTokens, + StopReason::StopSequence => NativeStopReason::StopSequence, + }), + tokens: c.tokens.map(|t| NativeTokenUsage { + input_tokens: t.input_tokens, + output_tokens: t.output_tokens, + total_tokens: t.total_tokens, + }), + model: c.model, + raw_format: c.raw_format, + created_at: c.created_at, + latency_ms: c.latency_ms, + } +} + +fn wit_framework_to_native(f: FrameworkExtension) -> NativeFrameworkExtension { + NativeFrameworkExtension { + framework: f.framework, + framework_version: f.framework_version, + node_id: f.node_id, + graph_id: f.graph_id, + metadata: f.metadata.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(), + } +} + +fn wit_delegation_to_native(d: DelegationExtension) -> NativeDelegationExtension { + NativeDelegationExtension { + chain: d.chain.into_iter().map(|hop| { + let strategy = match (hop.strategy, hop.strategy_custom) { + (Some(DelegationStrategy::TokenExchange), _) => Some(NativeDelegationStrategy::TokenExchange), + (Some(DelegationStrategy::ClientCredentials), _) => Some(NativeDelegationStrategy::ClientCredentials), + (Some(DelegationStrategy::SpiffeSvid), _) => Some(NativeDelegationStrategy::SpiffeSvid), + (Some(DelegationStrategy::Passthrough), _) => Some(NativeDelegationStrategy::Passthrough), + (Some(DelegationStrategy::Ucan), _) => Some(NativeDelegationStrategy::Ucan), + (Some(DelegationStrategy::TransactionToken), _) => Some(NativeDelegationStrategy::TransactionToken), + (None, Some(s)) => Some(NativeDelegationStrategy::Custom(s)), + (None, None) => None, + }; + NativeDelegationHop { + subject_id: hop.subject_id, + subject_type: hop.subject_type.map(wit_subject_type_to_native), + audience: hop.audience, + scopes_granted: hop.scopes_granted, + authorization_details: hop.authorization_details.into_iter() + .map(|a| NativeAuthDetail { + detail_type: a.detail_type, + locations: a.locations, + actions: a.actions, + datatypes: a.datatypes, + identifier: a.identifier, + privileges: a.privileges, + extra: a.extra + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(), + }) + .collect(), + timestamp: DateTime::parse_from_rfc3339(&hop.timestamp) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|e| { + eprintln!( + "[WASM] failed to parse delegation timestamp '{}': {} — substituting Utc::now()", + hop.timestamp, e + ); + chrono::Utc::now() + }), + ttl_seconds: hop.ttl_seconds, + strategy, + from_cache: hop.from_cache, + } + }).collect(), + depth: d.depth, + origin_subject_id: d.origin_subject_id, + actor_subject_id: d.actor_subject_id, + delegated: d.delegated, + age_seconds: d.age_seconds.parse().unwrap_or_else(|e| { + eprintln!( + "[WASM] failed to parse delegation age_seconds '{}': {} — defaulting to 0.0", + d.age_seconds, e + ); + 0.0 + }), + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: IdentityPayload +// --------------------------------------------------------------------------- + +/// Convert a WIT IdentityPayload to a native cpex-core IdentityPayload. +pub fn wit_identity_payload_to_native(p: IdentityPayload) -> NativeIdentityPayload { + let source = match p.source { + TokenSource::Bearer => NativeTokenSource::Bearer, + TokenSource::UserToken => NativeTokenSource::UserToken, + TokenSource::Mtls => NativeTokenSource::Mtls, + TokenSource::SpiffeJwtSvid => NativeTokenSource::SpiffeJwtSvid, + TokenSource::ApiKey => NativeTokenSource::ApiKey, + TokenSource::Custom => NativeTokenSource::Custom(p.source_custom.unwrap_or_default()), + }; + let mut out = NativeIdentityPayload::new("", source); + if let Some(h) = p.source_header { + out = out.with_source_header(h); + } + out = out.with_headers(p.headers.into_iter().collect()); + if let Some(h) = p.client_host { + out = out.with_client_host(h); + } + if let Some(port) = p.client_port { + out = out.with_client_port(port); + } + out.subject = p.subject.map(|s| 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(), + }); + out.client = p.client.map(wit_client_to_native); + out.caller_workload = p.caller_workload.map(wit_workload_to_native); + out.delegation = p.delegation.map(wit_delegation_to_native); + out.resolved_at = p + .resolved_at + .as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + out.raw_claims = p + .raw_claims + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + out +} + +// --------------------------------------------------------------------------- +// WIT → Native: DelegationPayload +// --------------------------------------------------------------------------- + +/// Convert a WIT DelegationPayload to a native cpex-core DelegationPayload. +pub fn wit_delegation_payload_to_native(p: DelegationPayload) -> NativeDelegationPayload { + let target_type = match p.target_type { + TargetType::Tool => NativeTargetType::Tool, + TargetType::Agent => NativeTargetType::Agent, + TargetType::Resource => NativeTargetType::Resource, + TargetType::Service => NativeTargetType::Service, + TargetType::Custom => NativeTargetType::Custom(p.target_type_custom.unwrap_or_default()), + }; + let auth_enforced_by = match p.auth_enforced_by { + AuthEnforcedBy::Caller => NativeAuthEnforcedBy::Caller, + AuthEnforcedBy::Target => NativeAuthEnforcedBy::Target, + AuthEnforcedBy::Both => NativeAuthEnforcedBy::Both, + }; + let mut out = NativeDelegationPayload::new("", p.target_name) + .with_target_type(target_type) + .with_auth_enforced_by(auth_enforced_by); + if let Some(aud) = p.target_audience { + out = out.with_target_audience(aud); + } + if !p.required_permissions.is_empty() { + out = out.with_required_permissions(p.required_permissions); + } + if let Some(td) = p.trust_domain { + out = out.with_trust_domain(td); + } + if let Some(att) = p.route_attenuation { + out = out.with_route_attenuation(NativeAttenuationConfig { + capabilities: att.capabilities, + resource_template: att.resource_template, + actions: att.actions, + ttl_seconds: att.ttl_seconds, + }); + } + out.delegated_token = p.delegated_token.map(|t| { + let expires_at = DateTime::parse_from_rfc3339(&t.expires_at) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|_| chrono::Utc::now()); + NativeRawDelegatedToken::new("", t.outbound_header, t.audience, t.scopes, expires_at) + }); + out.delegation_update = p.delegation_update.map(wit_delegation_to_native); + out.delegation_mode = p.delegation_mode.map(|m| match m { + DelegationMode::OnBehalfOfUser => NativeDelegationMode::OnBehalfOfUser, + DelegationMode::AsGateway => NativeDelegationMode::AsGateway, + }); + out.minted_at = p + .minted_at + .as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + out.metadata = p + .metadata + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + out +} + +// --------------------------------------------------------------------------- +// WIT → Native: PluginContext +// --------------------------------------------------------------------------- + +/// Convert a WIT PluginContext to a native cpex-core PluginContext. +pub fn wit_context_to_native(ctx: PluginContext) -> NativePluginContext { + NativePluginContext { + local_state: ctx.local_state.into_iter() + .map(|e| (e.key, serde_json::from_str(&e.value).unwrap_or(serde_json::Value::String(e.value)))) + .collect(), + global_state: ctx.global_state.into_iter() + .map(|e| (e.key, serde_json::from_str(&e.value).unwrap_or(serde_json::Value::String(e.value)))) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: PluginResult → HookResult +// --------------------------------------------------------------------------- + +/// Converts a typed `PluginResult

` to a WIT HookResult for any payload +/// type that can cross the WASM boundary. A modified `MessagePayload` goes +/// back structured (cmf variant); every other payload type is serialized +/// into the custom variant with its type discriminator, which the host's +/// PayloadSerializerRegistry uses to reconstruct the concrete type. +pub fn native_result_to_hook_result_generic

( + result: NativePluginResult

, + ctx: &NativePluginContext, +) -> HookResult +where + P: cpex_core::hooks::payload::WasmSerializablePayload + 'static, +{ + let modified_payload = result.modified_payload.and_then(|p| { + let any: &dyn std::any::Any = &p; + if let Some(mp) = any.downcast_ref::() { + Some(HookPayload::Cmf(native_payload_to_wit(mp.clone()))) + } else if let Some(ip) = any.downcast_ref::() { + Some(HookPayload::Identity(native_identity_payload_to_wit(ip))) + } else if let Some(dp) = any.downcast_ref::() { + Some(HookPayload::Delegation(native_delegation_payload_to_wit(dp))) + } else { + match p.to_wasm_bytes() { + Ok(bytes) => Some(HookPayload::Custom(CustomPayload { + payload_type: P::payload_type_name().to_string(), + payload_data: bytes, + })), + Err(e) => { + eprintln!( + "[WASM] failed to serialize modified payload '{}': {}", + P::payload_type_name(), + e + ); + None + } + } + } + }); + HookResult { + continue_processing: result.continue_processing, + modified_payload, + modified_extensions: result.modified_extensions.as_ref().map(native_owned_extensions_to_wit), + modified_context: Some(native_context_to_wit(ctx)), + violation: result.violation.map(|v| PluginViolation { + code: v.code, + reason: v.reason, + description: v.description, + details: serde_json::to_string(&v.details).unwrap_or_else(|_| "{}".to_string()), + plugin_name: v.plugin_name, + proto_error_code: v.proto_error_code, + }), + metadata: result.metadata.map(|v| serde_json::to_string(&v).unwrap_or_default()), + } +} + +pub(crate) fn native_context_to_wit(ctx: &NativePluginContext) -> PluginContext { + PluginContext { + local_state: ctx.local_state.iter() + .map(|(k, v)| ContextEntry { key: k.clone(), value: serde_json::to_string(v).unwrap_or_default() }) + .collect(), + global_state: ctx.global_state.iter() + .map(|(k, v)| ContextEntry { key: k.clone(), value: serde_json::to_string(v).unwrap_or_default() }) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: MessagePayload +// --------------------------------------------------------------------------- + +/// Convert a native cpex-core MessagePayload to a WIT MessagePayload. +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, + 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), + } +} + +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), + native_content::ContentPart::Thinking { text } => ContentPart::Thinking(text), + native_content::ContentPart::ToolCall { content } => ContentPart::ToolCall(ToolCall { + tool_call_id: content.tool_call_id, + name: content.name, + arguments: serde_json::to_string(&content.arguments).unwrap_or_else(|_| "{}".to_string()), + namespace: content.namespace, + }), + native_content::ContentPart::ToolResult { content } => ContentPart::ToolResult(ToolResult { + tool_call_id: content.tool_call_id, + tool_name: content.tool_name, + content: serde_json::to_string(&content.content).unwrap_or_default(), + is_error: content.is_error, + }), + native_content::ContentPart::Resource { content } => ContentPart::CmfResource(CmfResource { + resource_request_id: content.resource_request_id, + uri: content.uri, + name: content.name, + description: content.description, + resource_type: native_resource_type_to_wit(content.resource_type), + content: content.content, + blob: content.blob, + mime_type: content.mime_type, + size_bytes: content.size_bytes, + annotations: serde_json::to_string(&content.annotations).unwrap_or_else(|_| "{}".to_string()), + version: content.version, + }), + native_content::ContentPart::ResourceRef { content } => ContentPart::ResourceRef(ResourceReference { + resource_request_id: content.resource_request_id, + uri: content.uri, + name: content.name, + resource_type: native_resource_type_to_wit(content.resource_type), + range_start: content.range_start, + range_end: content.range_end, + selector: content.selector, + }), + native_content::ContentPart::PromptRequest { content } => ContentPart::PromptRequest(PromptRequest { + prompt_request_id: content.prompt_request_id, + name: content.name, + arguments: serde_json::to_string(&content.arguments).unwrap_or_else(|_| "{}".to_string()), + server_id: content.server_id, + }), + native_content::ContentPart::PromptResult { content } => ContentPart::PromptResult(PromptResult { + prompt_request_id: content.prompt_request_id, + prompt_name: content.prompt_name, + messages: serde_json::to_string(&content.messages).unwrap_or_else(|_| "[]".to_string()), + content: content.content, + is_error: content.is_error, + error_message: content.error_message, + }), + 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, + }), + } +} + +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, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: OwnedExtensions (from PluginResult::modified_extensions) +// --------------------------------------------------------------------------- + +/// Convert plugin-modified extensions back to WIT for the host. +/// +/// LIMITATION: The following extension slots are NOT converted and will be +/// silently discarded if a plugin modifies them: +/// - agent, mcp, completion, provenance, llm, framework, delegation, custom +/// - security.client, security.caller_workload, security.this_workload +/// - security.objects, security.data +/// +/// Only these are preserved across the boundary: +/// - request (environment, request_id, timestamp, trace_id, span_id) +/// - security (labels, classification, subject, auth_method) +/// - http (request_headers, response_headers, method, path, host, scheme) +/// - meta (entity_type, entity_name, tags, scope, properties) +// --------------------------------------------------------------------------- +// Native → WIT: IdentityPayload +// --------------------------------------------------------------------------- + +pub fn native_identity_payload_to_wit(p: &NativeIdentityPayload) -> IdentityPayload { + let (source, source_custom) = match p.source() { + NativeTokenSource::Bearer => (TokenSource::Bearer, None), + NativeTokenSource::UserToken => (TokenSource::UserToken, None), + NativeTokenSource::Mtls => (TokenSource::Mtls, None), + NativeTokenSource::SpiffeJwtSvid => (TokenSource::SpiffeJwtSvid, None), + NativeTokenSource::ApiKey => (TokenSource::ApiKey, None), + NativeTokenSource::Custom(s) => (TokenSource::Custom, Some(s.clone())), + _ => (TokenSource::Bearer, None), + }; + IdentityPayload { + source, + source_custom, + source_header: p.source_header().map(str::to_owned), + headers: p.headers().iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + client_host: p.client_host().map(str::to_owned), + client_port: p.client_port(), + subject: p.subject.as_ref().map(native_subject_to_wit), + client: p.client.as_ref().map(native_client_to_wit), + caller_workload: p.caller_workload.as_ref().map(native_workload_to_wit), + delegation: p.delegation.as_ref().map(native_delegation_ext_to_wit), + resolved_at: p.resolved_at.map(|dt| dt.to_rfc3339()), + raw_claims: if p.raw_claims.is_empty() { + None + } else { + serde_json::to_string(&p.raw_claims).ok() + }, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: DelegationPayload +// --------------------------------------------------------------------------- + +pub fn native_delegation_payload_to_wit(p: &NativeDelegationPayload) -> DelegationPayload { + let (target_type, target_type_custom) = match p.target_type() { + NativeTargetType::Tool => (TargetType::Tool, None), + NativeTargetType::Agent => (TargetType::Agent, None), + NativeTargetType::Resource => (TargetType::Resource, None), + NativeTargetType::Service => (TargetType::Service, None), + NativeTargetType::Custom(s) => (TargetType::Custom, Some(s.clone())), + _ => (TargetType::Tool, None), + }; + let auth_enforced_by = match p.auth_enforced_by() { + NativeAuthEnforcedBy::Caller => AuthEnforcedBy::Caller, + NativeAuthEnforcedBy::Target => AuthEnforcedBy::Target, + NativeAuthEnforcedBy::Both => AuthEnforcedBy::Both, + _ => AuthEnforcedBy::Caller, + }; + DelegationPayload { + target_name: p.target_name().to_owned(), + target_type, + target_type_custom, + target_audience: p.target_audience().map(str::to_owned), + required_permissions: p.required_permissions().to_vec(), + trust_domain: p.trust_domain().map(str::to_owned), + auth_enforced_by, + route_attenuation: p.route_attenuation().map(|a| AttenuationConfig { + capabilities: a.capabilities.clone(), + resource_template: a.resource_template.clone(), + actions: a.actions.clone(), + ttl_seconds: a.ttl_seconds, + }), + delegated_token: p.delegated_token.as_ref().map(|t| RawDelegatedToken { + outbound_header: t.outbound_header.clone(), + audience: t.audience.clone(), + scopes: t.scopes.clone(), + expires_at: t.expires_at.to_rfc3339(), + }), + delegation_update: p.delegation_update.as_ref().map(native_delegation_ext_to_wit), + delegation_mode: p.delegation_mode.as_ref().map(|m| match m { + NativeDelegationMode::OnBehalfOfUser => DelegationMode::OnBehalfOfUser, + NativeDelegationMode::AsGateway => DelegationMode::AsGateway, + _ => DelegationMode::OnBehalfOfUser, + }), + minted_at: p.minted_at.map(|dt| dt.to_rfc3339()), + metadata: if p.metadata.is_empty() { + None + } else { + serde_json::to_string(&p.metadata).ok() + }, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: shared helpers for payload conversions +// --------------------------------------------------------------------------- + +fn native_subject_to_wit(s: &NativeSubjectExtension) -> SubjectExtension { + SubjectExtension { + id: s.id.clone(), + subject_type: s.subject_type.as_ref().map(|st| match st { + NativeSubjectType::User => SubjectType::User, + NativeSubjectType::Agent => SubjectType::Agent, + NativeSubjectType::Service => SubjectType::Service, + NativeSubjectType::System => SubjectType::System, + }), + 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_client_to_wit(c: &NativeClientExtension) -> ClientExtension { + let (trust_level, trust_level_custom) = match &c.trust_level { + NativeClientTrustLevel::FirstParty => (ClientTrustLevel::FirstParty, None), + NativeClientTrustLevel::ThirdParty => (ClientTrustLevel::ThirdParty, None), + NativeClientTrustLevel::Internal => (ClientTrustLevel::Internal, None), + NativeClientTrustLevel::Custom(s) => (ClientTrustLevel::ThirdParty, Some(s.clone())), + _ => (ClientTrustLevel::ThirdParty, None), + }; + ClientExtension { + client_id: c.client_id.clone(), + client_name: c.client_name.clone(), + trust_level, + trust_level_custom, + authorized_scopes: c.authorized_scopes.clone(), + authorized_audiences: c.authorized_audiences.clone(), + roles: c.roles.clone(), + permissions: c.permissions.clone(), + teams: c.teams.clone(), + claims: c.claims.iter() + .map(|(k, v)| (k.clone(), serde_json::to_string(v).unwrap_or_default())) + .collect(), + } +} + +fn native_workload_to_wit(w: &NativeWorkloadIdentity) -> WorkloadIdentity { + WorkloadIdentity { + spiffe_id: w.spiffe_id.clone(), + trust_domain: w.trust_domain.clone(), + attested_at: w.attested_at.map(|dt| dt.to_rfc3339()), + attestor: w.attestor.clone(), + selectors: w.selectors.clone(), + client_id: w.client_id.clone(), + } +} + +fn native_delegation_ext_to_wit(d: &NativeDelegationExtension) -> DelegationExtension { + use cpex_core::extensions::delegation::DelegationStrategy as NDS; + DelegationExtension { + chain: d.chain.iter().map(|hop| { + let (strategy, strategy_custom) = match &hop.strategy { + None => (None, None), + Some(NDS::TokenExchange) => (Some(DelegationStrategy::TokenExchange), None), + Some(NDS::ClientCredentials) => (Some(DelegationStrategy::ClientCredentials), None), + Some(NDS::SpiffeSvid) => (Some(DelegationStrategy::SpiffeSvid), None), + Some(NDS::Passthrough) => (Some(DelegationStrategy::Passthrough), None), + Some(NDS::Ucan) => (Some(DelegationStrategy::Ucan), None), + Some(NDS::TransactionToken) => (Some(DelegationStrategy::TransactionToken), None), + Some(NDS::Custom(s)) => (None, Some(s.clone())), + Some(_) => (None, None), + }; + DelegationHop { + subject_id: hop.subject_id.clone(), + subject_type: hop.subject_type.as_ref().map(|st| match st { + NativeSubjectType::User => SubjectType::User, + NativeSubjectType::Agent => SubjectType::Agent, + NativeSubjectType::Service => SubjectType::Service, + NativeSubjectType::System => SubjectType::System, + }), + audience: hop.audience.clone(), + scopes_granted: hop.scopes_granted.clone(), + authorization_details: hop.authorization_details.iter().map(|a| AuthorizationDetail { + detail_type: a.detail_type.clone(), + locations: a.locations.clone(), + actions: a.actions.clone(), + datatypes: a.datatypes.clone(), + identifier: a.identifier.clone(), + privileges: a.privileges.clone(), + extra: if a.extra.is_empty() { None } else { serde_json::to_string(&a.extra).ok() }, + }).collect(), + timestamp: hop.timestamp.to_rfc3339(), + ttl_seconds: hop.ttl_seconds, + strategy, + strategy_custom, + from_cache: hop.from_cache, + } + }).collect(), + depth: d.depth, + origin_subject_id: d.origin_subject_id.clone(), + actor_subject_id: d.actor_subject_id.clone(), + delegated: d.delegated, + age_seconds: d.age_seconds.to_string(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: OwnedExtensions (for result writeback) +// --------------------------------------------------------------------------- + +pub(crate) fn native_owned_extensions_to_wit( + ext: &cpex_core::extensions::container::OwnedExtensions, +) -> Extensions { + Extensions { + request: ext.request.as_ref().map(|r| 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(), + }), + security: ext.security.as_ref().map(|s| SecurityExtension { + labels: s.labels.iter().cloned().collect(), + classification: s.classification.clone(), + subject: s.subject.as_ref().map(|sub| SubjectExtension { + id: sub.id.clone(), + subject_type: sub.subject_type.as_ref().map(|st| match st { + NativeSubjectType::User => SubjectType::User, + NativeSubjectType::Agent => SubjectType::Agent, + NativeSubjectType::Service => SubjectType::Service, + NativeSubjectType::System => SubjectType::System, + }), + roles: sub.roles.iter().cloned().collect(), + permissions: sub.permissions.iter().cloned().collect(), + teams: sub.teams.iter().cloned().collect(), + claims: sub.claims.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + }), + client: None, + caller_workload: None, + this_workload: None, + auth_method: s.auth_method.clone(), + objects: vec![], + data: vec![], + }), + http: ext.http.as_ref().map(|h| HttpExtension { + request_headers: h.read().request_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + response_headers: h.read().response_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + method: h.read().method.clone(), + path: h.read().path.clone(), + host: h.read().host.clone(), + scheme: h.read().scheme.clone(), + }), + meta: ext.meta.as_ref().map(|m| 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(), + }), + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + delegation: None, + custom: None, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::constants::SCHEMA_VERSION; + + // ── MessagePayload roundtrip ───────────────────────────────────────────── + + #[test] + fn test_payload_roundtrip_text_message() { + let native = native_msg::MessagePayload { + message: native_msg::Message { + schema_version: SCHEMA_VERSION.into(), + role: native_enums::Role::User, + content: vec![native_content::ContentPart::Text { + text: "hello world".into(), + }], + channel: None, + }, + }; + + let wit = native_payload_to_wit(native.clone()); + let back = wit_payload_to_native(wit); + + assert_eq!(back.message.schema_version, native.message.schema_version); + assert_eq!(back.message.content.len(), 1); + match &back.message.content[0] { + native_content::ContentPart::Text { text } => assert_eq!(text, "hello world"), + _ => panic!("expected Text"), + } + } + + #[test] + fn test_payload_roundtrip_tool_call() { + let mut args = HashMap::new(); + args.insert("key".to_string(), serde_json::json!("value")); + args.insert("num".to_string(), serde_json::json!(42)); + + let native = native_msg::MessagePayload { + message: native_msg::Message { + schema_version: SCHEMA_VERSION.into(), + role: native_enums::Role::Assistant, + content: vec![native_content::ContentPart::ToolCall { + content: native_content::ToolCall { + tool_call_id: "tc_001".into(), + name: "get_data".into(), + arguments: args.clone(), + namespace: Some("mcp".into()), + }, + }], + channel: Some(native_enums::Channel::Final), + }, + }; + + let wit = native_payload_to_wit(native); + let back = wit_payload_to_native(wit); + + assert_eq!(back.message.channel, Some(native_enums::Channel::Final)); + match &back.message.content[0] { + native_content::ContentPart::ToolCall { content } => { + assert_eq!(content.tool_call_id, "tc_001"); + assert_eq!(content.name, "get_data"); + assert_eq!(content.arguments, args); + assert_eq!(content.namespace, Some("mcp".into())); + } + _ => panic!("expected ToolCall"), + } + } + + #[test] + fn test_payload_roundtrip_tool_result() { + let native = native_msg::MessagePayload { + message: native_msg::Message { + schema_version: SCHEMA_VERSION.into(), + role: native_enums::Role::Tool, + content: vec![native_content::ContentPart::ToolResult { + content: native_content::ToolResult { + tool_call_id: "tc_001".into(), + tool_name: "get_data".into(), + content: serde_json::json!({"result": "ok"}), + is_error: false, + }, + }], + channel: None, + }, + }; + + let wit = native_payload_to_wit(native); + let back = wit_payload_to_native(wit); + + match &back.message.content[0] { + native_content::ContentPart::ToolResult { content } => { + assert_eq!(content.tool_call_id, "tc_001"); + assert_eq!(content.tool_name, "get_data"); + assert_eq!(content.content, serde_json::json!({"result": "ok"})); + assert!(!content.is_error); + } + _ => panic!("expected ToolResult"), + } + } + + #[test] + fn test_payload_roundtrip_all_roles() { + let roles = vec![ + (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), + ]; + + for (native_role, _) in roles { + let native = native_msg::MessagePayload { + message: native_msg::Message { + schema_version: SCHEMA_VERSION.into(), + role: native_role, + content: vec![], + channel: None, + }, + }; + let wit = native_payload_to_wit(native.clone()); + let back = wit_payload_to_native(wit); + assert_eq!(back.message.role, native.message.role); + } + } + + // ── PluginContext roundtrip ────────────────────────────────────────────── + + #[test] + fn test_context_roundtrip_empty() { + let native = NativePluginContext::default(); + let wit = native_context_to_wit(&native); + let back = wit_context_to_native(wit); + + assert!(back.local_state.is_empty()); + assert!(back.global_state.is_empty()); + } + + #[test] + fn test_context_roundtrip_with_state() { + let mut native = NativePluginContext::default(); + native.set_local("key1", serde_json::json!("value1")); + native.set_local("key2", serde_json::json!(42)); + native.set_global("global_key", serde_json::json!({"nested": true})); + + let wit = native_context_to_wit(&native); + let back = wit_context_to_native(wit); + + assert_eq!(back.get_local("key1").unwrap(), &serde_json::json!("value1")); + assert_eq!(back.get_local("key2").unwrap(), &serde_json::json!(42)); + assert_eq!( + back.get_global("global_key").unwrap(), + &serde_json::json!({"nested": true}) + ); + } + + // ── Extensions: WIT → Native ───────────────────────────────────────────── + + #[test] + fn test_extensions_empty() { + let wit = Extensions { + request: None, + security: None, + http: None, + meta: None, + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + delegation: None, + custom: None, + }; + + let native = wit_extensions_to_native(wit); + assert!(native.request.is_none()); + assert!(native.security.is_none()); + assert!(native.http.is_none()); + } + + #[test] + fn test_extensions_security_labels() { + let wit = Extensions { + security: Some(SecurityExtension { + labels: vec!["PII".into(), "SENSITIVE".into()], + classification: Some("confidential".into()), + subject: None, + client: None, + caller_workload: None, + this_workload: None, + auth_method: Some("jwt".into()), + objects: vec![], + data: vec![], + }), + request: None, + http: None, + meta: None, + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + delegation: None, + custom: None, + }; + + let native = wit_extensions_to_native(wit); + let sec = native.security.unwrap(); + assert!(sec.has_label("PII")); + assert!(sec.has_label("SENSITIVE")); + assert_eq!(sec.classification, Some("confidential".into())); + assert_eq!(sec.auth_method, Some("jwt".into())); + } + + #[test] + fn test_extensions_security_subject() { + let wit = Extensions { + security: Some(SecurityExtension { + labels: vec![], + classification: None, + subject: Some(SubjectExtension { + id: Some("user-123".into()), + subject_type: Some(SubjectType::User), + roles: vec!["admin".into(), "reader".into()], + permissions: vec!["read".into(), "write".into()], + teams: vec!["engineering".into()], + claims: vec![("org".into(), "acme".into())], + }), + client: None, + caller_workload: None, + this_workload: None, + auth_method: None, + objects: vec![], + data: vec![], + }), + request: None, + http: None, + meta: None, + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + delegation: None, + custom: None, + }; + + let native = wit_extensions_to_native(wit); + let sec = native.security.unwrap(); + let sub = sec.subject.as_ref().unwrap(); + assert_eq!(sub.id, Some("user-123".into())); + assert_eq!(sub.subject_type, Some(NativeSubjectType::User)); + assert!(sub.roles.contains("admin")); + assert!(sub.roles.contains("reader")); + assert!(sub.permissions.contains("write")); + assert!(sub.teams.contains("engineering")); + assert_eq!(sub.claims.get("org").unwrap(), "acme"); + } + + #[test] + fn test_extensions_http_headers() { + let wit = Extensions { + http: Some(HttpExtension { + request_headers: vec![ + ("Authorization".into(), "Bearer tok".into()), + ("X-Request-ID".into(), "req-1".into()), + ], + response_headers: vec![("X-Powered-By".into(), "cpex".into())], + method: Some("POST".into()), + path: Some("/api/v1/tools".into()), + host: Some("example.com".into()), + scheme: Some("https".into()), + }), + request: None, + security: None, + meta: None, + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + delegation: None, + custom: None, + }; + + let native = wit_extensions_to_native(wit); + let http = native.http.unwrap(); + assert_eq!( + http.request_headers.get("Authorization").unwrap(), + "Bearer tok" + ); + assert_eq!(http.request_headers.get("X-Request-ID").unwrap(), "req-1"); + assert_eq!( + http.response_headers.get("X-Powered-By").unwrap(), + "cpex" + ); + assert_eq!(http.method, Some("POST".into())); + assert_eq!(http.path, Some("/api/v1/tools".into())); + } + + #[test] + fn test_extensions_request_metadata() { + let wit = Extensions { + request: Some(RequestExtension { + environment: Some("production".into()), + request_id: Some("req-abc".into()), + timestamp: Some("2026-07-28T12:00:00Z".into()), + trace_id: Some("trace-1".into()), + span_id: Some("span-1".into()), + }), + security: None, + http: None, + meta: None, + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + delegation: None, + custom: None, + }; + + let native = wit_extensions_to_native(wit); + let req = native.request.unwrap(); + assert_eq!(req.environment, Some("production".into())); + assert_eq!(req.request_id, Some("req-abc".into())); + assert_eq!(req.trace_id, Some("trace-1".into())); + } + + // ── OwnedExtensions: Native → WIT (and verify what's lost) ─────────────── + + #[test] + fn test_owned_extensions_security_roundtrip() { + use cpex_core::extensions::container::OwnedExtensions; + + let mut sec = NativeSecurityExtension::default(); + sec.labels = cpex_core::extensions::monotonic::MonotonicSet::from_set( + ["PII".to_string(), "AUDIT".to_string()].into_iter().collect(), + ); + sec.subject = Some(NativeSubjectExtension { + id: Some("user-1".into()), + subject_type: Some(NativeSubjectType::Agent), + roles: ["admin".into()].into_iter().collect(), + permissions: HashSet::new(), + teams: HashSet::new(), + claims: HashMap::new(), + }); + sec.auth_method = Some("mtls".into()); + + let owned = OwnedExtensions { + security: Some(sec), + request: None, + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + meta: None, + raw_credentials: None, + http: None, + delegation: None, + custom: None, + http_write_token: None, + labels_write_token: None, + delegation_write_token: None, + }; + + let wit = native_owned_extensions_to_wit(&owned); + let wit_sec = wit.security.unwrap(); + + assert!(wit_sec.labels.contains(&"PII".to_string())); + assert!(wit_sec.labels.contains(&"AUDIT".to_string())); + assert_eq!(wit_sec.subject.as_ref().unwrap().id, Some("user-1".into())); + assert_eq!( + wit_sec.subject.as_ref().unwrap().subject_type, + Some(SubjectType::Agent) + ); + assert!(wit_sec.subject.as_ref().unwrap().roles.contains(&"admin".to_string())); + assert_eq!(wit_sec.auth_method, Some("mtls".into())); + } + + #[test] + fn test_owned_extensions_http_roundtrip() { + use cpex_core::extensions::container::OwnedExtensions; + use cpex_core::extensions::guarded::Guarded; + + let http = NativeHttpExtension { + request_headers: [("Host".into(), "example.com".into())].into_iter().collect(), + response_headers: HashMap::new(), + method: Some("GET".into()), + path: Some("/test".into()), + host: Some("example.com".into()), + scheme: Some("https".into()), + }; + + let owned = OwnedExtensions { + http: Some(Guarded::new(http)), + security: None, + request: None, + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + meta: None, + raw_credentials: None, + delegation: None, + custom: None, + http_write_token: None, + labels_write_token: None, + delegation_write_token: None, + }; + + let wit = native_owned_extensions_to_wit(&owned); + let wit_http = wit.http.unwrap(); + + assert!(wit_http.request_headers.contains(&("Host".into(), "example.com".into()))); + assert_eq!(wit_http.method, Some("GET".into())); + assert_eq!(wit_http.path, Some("/test".into())); + } + + #[test] + fn test_owned_extensions_drops_unsupported_fields() { + use cpex_core::extensions::container::OwnedExtensions; + + let owned = OwnedExtensions { + security: None, + request: None, + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + meta: None, + raw_credentials: None, + http: None, + delegation: None, + custom: None, + http_write_token: None, + labels_write_token: None, + delegation_write_token: None, + }; + + let wit = native_owned_extensions_to_wit(&owned); + + assert!(wit.agent.is_none()); + assert!(wit.mcp.is_none()); + assert!(wit.completion.is_none()); + assert!(wit.provenance.is_none()); + assert!(wit.llm.is_none()); + assert!(wit.framework.is_none()); + assert!(wit.delegation.is_none()); + assert!(wit.custom.is_none()); + } + + // ── Edge cases ─────────────────────────────────────────────────────────── + + #[test] + fn test_tool_call_malformed_json_arguments() { + let wit_payload = MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall(ToolCall { + tool_call_id: "tc_1".into(), + name: "test".into(), + arguments: "not valid json{{{".into(), + namespace: None, + })], + channel: None, + }, + }; + + let native = wit_payload_to_native(wit_payload); + match &native.message.content[0] { + native_content::ContentPart::ToolCall { content } => { + assert!(content.arguments.is_empty()); + } + _ => panic!("expected ToolCall"), + } + } + + #[test] + fn test_tool_call_empty_arguments() { + let wit_payload = MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall(ToolCall { + tool_call_id: "tc_1".into(), + name: "test".into(), + arguments: "{}".into(), + namespace: None, + })], + channel: None, + }, + }; + + let native = wit_payload_to_native(wit_payload); + match &native.message.content[0] { + native_content::ContentPart::ToolCall { content } => { + assert!(content.arguments.is_empty()); + } + _ => panic!("expected ToolCall"), + } + } + + #[test] + fn test_unicode_in_tool_name_and_arguments() { + let mut args = HashMap::new(); + args.insert("query".to_string(), serde_json::json!("日本語テスト")); + + let native = native_msg::MessagePayload { + message: native_msg::Message { + schema_version: SCHEMA_VERSION.into(), + role: native_enums::Role::Assistant, + content: vec![native_content::ContentPart::ToolCall { + content: native_content::ToolCall { + tool_call_id: "tc_unicode".into(), + name: "검색_도구".into(), + arguments: args.clone(), + namespace: None, + }, + }], + channel: None, + }, + }; + + let wit = native_payload_to_wit(native); + let back = wit_payload_to_native(wit); + + match &back.message.content[0] { + native_content::ContentPart::ToolCall { content } => { + assert_eq!(content.name, "검색_도구"); + assert_eq!( + content.arguments.get("query").unwrap(), + &serde_json::json!("日本語テスト") + ); + } + _ => panic!("expected ToolCall"), + } + } + + #[test] + fn test_context_with_nested_json_values() { + let mut native = NativePluginContext::default(); + let complex = serde_json::json!({ + "array": [1, 2, 3], + "nested": {"deep": {"value": true}}, + "null_field": null + }); + native.set_local("complex", complex.clone()); + + let wit = native_context_to_wit(&native); + let back = wit_context_to_native(wit); + + assert_eq!(back.get_local("complex").unwrap(), &complex); + } + + #[test] + fn test_empty_strings_and_vectors() { + let native = native_msg::MessagePayload { + message: native_msg::Message { + schema_version: "".into(), + role: native_enums::Role::User, + content: vec![], + channel: None, + }, + }; + + let wit = native_payload_to_wit(native); + let back = wit_payload_to_native(wit); + + assert_eq!(back.message.schema_version, ""); + assert!(back.message.content.is_empty()); + } + + #[test] + fn test_extensions_agent_roundtrip() { + let wit = Extensions { + agent: Some(AgentExtension { + input: Some("user query".into()), + session_id: Some("sess-1".into()), + conversation_id: Some("conv-1".into()), + turn: Some(3), + agent_id: Some("agent-1".into()), + parent_agent_id: None, + conversation: None, + }), + request: None, + security: None, + http: None, + meta: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + delegation: None, + custom: None, + }; + + let native = wit_extensions_to_native(wit); + let agent = native.agent.unwrap(); + assert_eq!(agent.input, Some("user query".into())); + assert_eq!(agent.session_id, Some("sess-1".into())); + assert_eq!(agent.turn, Some(3)); + } + + #[test] + fn test_extensions_completion_roundtrip() { + let wit = Extensions { + completion: Some(CompletionExtension { + stop_reason: Some(StopReason::MaxTokens), + tokens: Some(TokenUsage { + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + }), + model: Some("claude-opus-4".into()), + raw_format: None, + created_at: Some("2026-07-28T12:00:00Z".into()), + latency_ms: Some(1500), + }), + request: None, + security: None, + http: None, + meta: None, + agent: None, + mcp: None, + provenance: None, + llm: None, + framework: None, + delegation: None, + custom: None, + }; + + let native = wit_extensions_to_native(wit); + let comp = native.completion.unwrap(); + assert_eq!(comp.stop_reason, Some(NativeStopReason::MaxTokens)); + assert_eq!(comp.tokens.as_ref().unwrap().input_tokens, 100); + assert_eq!(comp.tokens.as_ref().unwrap().total_tokens, 300); + assert_eq!(comp.model, Some("claude-opus-4".into())); + assert_eq!(comp.latency_ms, Some(1500)); + } + + // ── Identity Payload Conversion ──────────────────────────────────────── + + #[test] + fn test_identity_payload_minimal() { + let wit = IdentityPayload { + source: TokenSource::Bearer, + source_custom: None, + source_header: None, + headers: vec![], + client_host: None, + client_port: None, + subject: None, + client: None, + caller_workload: None, + delegation: None, + resolved_at: None, + raw_claims: None, + }; + + let native = super::wit_identity_payload_to_native(wit); + assert!(matches!(native.source(), cpex_core::identity::TokenSource::Bearer)); + assert!(native.subject.is_none()); + assert!(native.client.is_none()); + assert!(native.caller_workload.is_none()); + assert!(native.delegation.is_none()); + assert!(native.resolved_at.is_none()); + assert!(native.raw_claims.is_empty()); + } + + #[test] + fn test_identity_payload_with_subject_and_headers() { + let wit = IdentityPayload { + source: TokenSource::Custom, + source_custom: Some("oauth2".into()), + source_header: Some("x-custom-auth".into()), + headers: vec![ + ("x-user-id".into(), "alice".into()), + ("authorization".into(), "Bearer tok".into()), + ], + client_host: Some("10.0.0.1".into()), + client_port: Some(443), + subject: Some(SubjectExtension { + id: Some("alice".into()), + subject_type: Some(SubjectType::User), + roles: vec!["admin".into()], + permissions: vec!["read".into(), "write".into()], + teams: vec!["engineering".into()], + claims: vec![("org".into(), "acme".into())], + }), + client: None, + caller_workload: None, + delegation: None, + resolved_at: Some("2026-01-15T10:30:00Z".into()), + raw_claims: Some(r#"{"sub":"alice","iss":"idp"}"#.into()), + }; + + let native = super::wit_identity_payload_to_native(wit); + assert!(matches!(native.source(), cpex_core::identity::TokenSource::Custom(s) if s == "oauth2")); + assert_eq!(native.source_header(), Some("x-custom-auth")); + assert_eq!(native.headers().get("x-user-id"), Some(&"alice".to_string())); + assert_eq!(native.client_host(), Some("10.0.0.1")); + assert_eq!(native.client_port(), Some(443)); + + let subject = native.subject.unwrap(); + assert_eq!(subject.id.as_deref(), Some("alice")); + assert_eq!(subject.subject_type, Some(NativeSubjectType::User)); + assert!(subject.roles.contains("admin")); + assert!(subject.permissions.contains("read")); + assert!(subject.teams.contains("engineering")); + + assert!(native.resolved_at.is_some()); + assert_eq!(native.raw_claims.get("sub").and_then(|v| v.as_str()), Some("alice")); + } + + // ── Delegation Payload Conversion ────────────────────────────────────── + + #[test] + fn test_delegation_payload_minimal() { + let wit = DelegationPayload { + target_name: "my-tool".into(), + target_type: TargetType::Tool, + target_type_custom: None, + target_audience: None, + required_permissions: vec![], + trust_domain: None, + auth_enforced_by: AuthEnforcedBy::Caller, + route_attenuation: None, + delegated_token: None, + delegation_update: None, + delegation_mode: None, + minted_at: None, + metadata: None, + }; + + let native = super::wit_delegation_payload_to_native(wit); + assert_eq!(native.target_name(), "my-tool"); + assert!(matches!(native.target_type(), NativeTargetType::Tool)); + assert!(matches!(native.auth_enforced_by(), NativeAuthEnforcedBy::Caller)); + assert!(native.delegated_token.is_none()); + assert!(native.delegation_update.is_none()); + assert!(native.delegation_mode.is_none()); + } + + #[test] + fn test_delegation_payload_full() { + let wit = DelegationPayload { + target_name: "query-svc".into(), + target_type: TargetType::Service, + target_type_custom: None, + target_audience: Some("https://api.example.com".into()), + required_permissions: vec!["read:data".into(), "write:logs".into()], + trust_domain: Some("corp.internal".into()), + auth_enforced_by: AuthEnforcedBy::Both, + route_attenuation: Some(AttenuationConfig { + capabilities: vec!["read".into()], + resource_template: Some("/api/v1/*".into()), + actions: vec!["GET".into()], + ttl_seconds: Some(300), + }), + delegated_token: Some(RawDelegatedToken { + outbound_header: "X-Service-Token".into(), + audience: "https://api.example.com".into(), + scopes: vec!["read:data".into()], + expires_at: "2026-06-15T12:00:00Z".into(), + }), + delegation_update: None, + delegation_mode: Some(DelegationMode::AsGateway), + minted_at: Some("2026-06-15T11:55:00Z".into()), + metadata: Some(r#"{"minter":"test"}"#.into()), + }; + + let native = super::wit_delegation_payload_to_native(wit); + assert_eq!(native.target_name(), "query-svc"); + assert!(matches!(native.target_type(), NativeTargetType::Service)); + assert_eq!(native.target_audience(), Some("https://api.example.com")); + assert_eq!(native.required_permissions(), &["read:data", "write:logs"]); + assert_eq!(native.trust_domain(), Some("corp.internal")); + assert!(matches!(native.auth_enforced_by(), NativeAuthEnforcedBy::Both)); + + let att = native.route_attenuation().unwrap(); + assert_eq!(att.capabilities, vec!["read"]); + assert_eq!(att.resource_template.as_deref(), Some("/api/v1/*")); + assert_eq!(att.ttl_seconds, Some(300)); + + let token = native.delegated_token.as_ref().unwrap(); + assert_eq!(token.outbound_header, "X-Service-Token"); + assert_eq!(token.audience, "https://api.example.com"); + assert_eq!(token.scopes, vec!["read:data"]); + + assert_eq!(native.delegation_mode, Some(NativeDelegationMode::AsGateway)); + assert!(native.minted_at.is_some()); + assert_eq!(native.metadata.get("minter").and_then(|v| v.as_str()), Some("test")); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/audit_logger.rs b/crates/cpex-wasm-plugin/src/examples/audit_logger.rs new file mode 100644 index 00000000..c48180c5 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/audit_logger.rs @@ -0,0 +1,190 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/audit_logger.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct AuditLoggerPlugin; + +impl Default for AuditLoggerPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for AuditLoggerPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "audit-logger".to_string(), + kind: "wasm://audit-logger.wasm".to_string(), + hooks: vec![ + "cmf.tool_pre_invoke".to_string(), + "cmf.tool_post_invoke".to_string(), + ], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for AuditLoggerPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let is_result = payload.message.is_tool_result(); + let phase = if is_result { "POST" } else { "PRE" }; + + let tool_name = if is_result { + payload + .message + .get_tool_results() + .first() + .map(|tr| tr.tool_name.as_str()) + .unwrap_or("unknown") + } else { + payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown") + }; + + let labels_str = extensions + .security + .as_ref() + .map(|s| { + let labels: Vec<&String> = s.labels.iter().collect(); + format!("{:?}", labels) + }) + .unwrap_or_else(|| "[]".into()); + + let req_id = extensions + .http + .as_ref() + .and_then(|h| h.get_header("X-Request-ID")) + .unwrap_or_default(); + + if is_result { + let is_error = payload + .message + .get_tool_results() + .first() + .map(|tr| tr.is_error) + .unwrap_or(false); + cpex_log!(info, "AUDIT[{}]: tool='{}' labels={} request_id='{}' error={}", + phase, tool_name, labels_str, req_id, is_error); + } else { + cpex_log!(info, "AUDIT[{}]: tool='{}' labels={} request_id='{}'", + phase, tool_name, labels_str, req_id); + } + PluginResult::allow() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use cpex_core::cmf::constants::SCHEMA_VERSION; + use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall, ToolResult}; + use cpex_core::context::PluginContext; + use cpex_core::extensions::container::Extensions; + use cpex_core::extensions::http::HttpExtension; + use cpex_core::extensions::security::SecurityExtension; + use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; + + use super::AuditLoggerPlugin; + + fn tool_call_payload(name: &str) -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: format!("tc_{}", name), + name: name.into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } + } + + fn tool_result_payload(name: &str, content: serde_json::Value, is_error: bool) -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Tool, + content: vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: format!("tc_{}", name), + tool_name: name.into(), + content, + is_error, + }, + }], + channel: None, + }, + } + } + + #[tokio::test] + async fn test_always_allows_pre_invoke() { + let mut sec = SecurityExtension::default(); + sec.add_label("PII"); + let mut http = HttpExtension::default(); + http.set_header("X-Request-ID", "req-123"); + let ext = Extensions { + security: Some(Arc::new(sec)), + http: Some(Arc::new(http)), + ..Default::default() + }; + let payload = tool_call_payload("get_data"); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &AuditLoggerPlugin, &payload, &ext, &mut ctx, + ).await; + assert!(result.continue_processing, "expected ALLOW"); + } + + #[tokio::test] + async fn test_always_allows_post_invoke() { + let ext = Extensions::default(); + let payload = tool_result_payload("get_data", serde_json::json!({"result": "ok"}), false); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &AuditLoggerPlugin, &payload, &ext, &mut ctx, + ).await; + assert!(result.continue_processing, "expected ALLOW"); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/audit_logger_custom.rs b/crates/cpex-wasm-plugin/src/examples/audit_logger_custom.rs new file mode 100644 index 00000000..75d3a83b --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/audit_logger_custom.rs @@ -0,0 +1,142 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/audit_logger_custom.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// AuditLoggerCustomPlugin — WASM audit logger for the custom payload demo. +// +// Mirrors the native AuditLogger from plugin_demo.rs: logs all tool invocations +// without blocking. Runs as fire_and_forget mode at priority 100. + + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +// --------------------------------------------------------------------------- +// Payload and hook types (same definition as all demo plugins) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + pub tool_name: String, + pub user: String, + pub arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +pub struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +pub struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} + +// --------------------------------------------------------------------------- +// Plugin implementation +// --------------------------------------------------------------------------- + +pub struct AuditLoggerCustomPlugin; + +impl Default for AuditLoggerCustomPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for AuditLoggerCustomPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "audit-logger".to_string(), + kind: "wasm://audit-logger-custom.wasm".to_string(), + hooks: vec!["tool_pre_invoke".to_string(), "tool_post_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for AuditLoggerCustomPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!( + info, + "[audit-logger] LOG: user='{}' tool='{}' args='{}'", + payload.user, + payload.tool_name, + payload.arguments + ); + PluginResult::allow() + } +} + +impl HookHandler for AuditLoggerCustomPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!( + info, + "[audit-logger] LOG: post-invoke user='{}' tool='{}'", + payload.user, + payload.tool_name + ); + PluginResult::allow() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_always_allows() { + let plugin = AuditLoggerCustomPlugin; + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/compute_bench.rs b/crates/cpex-wasm-plugin/src/examples/compute_bench.rs new file mode 100644 index 00000000..3ef3a949 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/compute_bench.rs @@ -0,0 +1,105 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/compute_bench.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// ComputeBenchPlugin — WASM plugin that performs real computation. +// +// Used for benchmarking WASM vs native performance on identical workloads: +// JSON parsing, string manipulation, and hash computation. The native +// benchmark does the exact same operations so the comparison isolates +// the runtime difference (not the workload difference). + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +pub struct ComputeBenchPlugin; + +impl Default for ComputeBenchPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for ComputeBenchPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "compute-bench".to_string(), + kind: "wasm://compute-bench.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for ComputeBenchPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // 1. JSON parsing — serialize tool call arguments to a string + let args_json = payload + .message + .get_tool_calls() + .first() + .map(|tc| serde_json::to_string(&tc.arguments).unwrap_or_default()) + .unwrap_or_default(); + + // 2. String manipulation — build a summary from payload + extensions + let mut summary = String::with_capacity(256); + summary.push_str("tool="); + summary.push_str( + payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("?"), + ); + if let Some(ref sec) = extensions.security { + for label in sec.labels.iter() { + summary.push_str(",label="); + summary.push_str(label); + } + } + if let Some(ref http) = extensions.http { + if let Some(req_id) = http.get_header("X-Request-ID") { + summary.push_str(",req_id="); + summary.push_str(req_id); + } + } + + // 3. Hash computation — simple FNV-like hash over the JSON bytes + let hash: u64 = args_json + .bytes() + .fold(14695981039346656037u64, |acc, b| { + acc.wrapping_mul(1099511628211).wrapping_add(b as u64) + }); + + // 4. Store computed results in context (exercises context write path) + ctx.set_local("hash", serde_json::json!(hash)); + ctx.set_local("summary_len", serde_json::json!(summary.len())); + + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/env_sandbox_demo.rs b/crates/cpex-wasm-plugin/src/examples/env_sandbox_demo.rs new file mode 100644 index 00000000..816e91f3 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/env_sandbox_demo.rs @@ -0,0 +1,202 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/env_sandbox_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// EnvSandboxDemoPlugin — demonstrates WASI environment variable sandbox enforcement. +// +// Reads one argument from the ToolCall payload: +// "env_var" — name of the environment variable to look up +// +// Calls std::env::var on that name. If WASI injected the variable (it was +// declared in allowed_env), the call succeeds and the plugin returns ALLOW +// with the value stored in ctx. If the variable was not declared (or the host +// does not have it set), std::env::var returns Err and the plugin returns DENY +// with violation code "env_access_denied". + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, ContentPart, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct EnvSandboxDemoPlugin; + +impl Default for EnvSandboxDemoPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for EnvSandboxDemoPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "env-sandbox-demo".to_string(), + kind: "wasm://env-sandbox-demo.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for EnvSandboxDemoPlugin { + async fn handle( + &self, + payload: &MessagePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + let env_var = match extract_var_name(payload) { + Some(v) => v, + None => { + cpex_log!(warn, "[env-sandbox-demo] no tool call with env_var found — allow"); + return PluginResult::allow(); + } + }; + + cpex_log!(info, "[env-sandbox-demo] looking up env_var='{}'", env_var); + + match std::env::var(&env_var) { + Ok(value) => { + cpex_log!(info, "[env-sandbox-demo] ALLOW: '{}' is visible", env_var); + ctx.set_local("env_var", serde_json::json!(env_var)); + ctx.set_local("env_result", serde_json::json!("allowed")); + ctx.set_local("env_value", serde_json::json!(value)); + PluginResult::allow() + } + Err(e) => { + cpex_log!(warn, "[env-sandbox-demo] DENY: '{}' not visible — {}", env_var, e); + ctx.set_local("env_var", serde_json::json!(env_var)); + ctx.set_local("env_result", serde_json::json!("denied")); + ctx.set_local("env_error", serde_json::json!(e.to_string())); + PluginResult::deny(PluginViolation::new( + "env_access_denied", + &format!( + "environment variable '{}' not visible in sandbox: {}", + env_var, e + ), + )) + } + } + } +} + +fn extract_var_name(payload: &MessagePayload) -> Option { + for part in &payload.message.content { + if let ContentPart::ToolCall { content: tc } = part { + return tc.arguments.get("env_var")?.as_str().map(|s| s.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::constants::SCHEMA_VERSION; + use cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; + use cpex_core::hooks::trait_def::HookHandler; + use std::collections::HashMap; + + fn make_payload(env_var: &str) -> MessagePayload { + let mut arguments = HashMap::new(); + arguments.insert("env_var".to_string(), serde_json::json!(env_var)); + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_env_demo".into(), + name: "env_sandbox_demo".into(), + arguments, + namespace: None, + }, + }], + channel: None, + }, + } + } + + #[tokio::test] + async fn test_var_present_on_host_is_allowed() { + std::env::set_var("CPEX_UNIT_TEST_VAR", "test-value-123"); + + let plugin = EnvSandboxDemoPlugin; + let payload = make_payload("CPEX_UNIT_TEST_VAR"); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + + assert!(result.continue_processing, "expected ALLOW"); + assert_eq!(ctx.get_local("env_result").unwrap(), "allowed"); + assert_eq!(ctx.get_local("env_value").unwrap(), "test-value-123"); + + std::env::remove_var("CPEX_UNIT_TEST_VAR"); + } + + #[tokio::test] + async fn test_var_absent_from_host_is_denied() { + std::env::remove_var("CPEX_NONEXISTENT_XYZ_99"); + + let plugin = EnvSandboxDemoPlugin; + let payload = make_payload("CPEX_NONEXISTENT_XYZ_99"); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + + assert!(!result.continue_processing, "expected DENY"); + assert_eq!(result.violation.as_ref().unwrap().code, "env_access_denied"); + assert_eq!(ctx.get_local("env_result").unwrap(), "denied"); + } + + #[tokio::test] + async fn test_missing_env_var_arg_passes_through() { + let payload = MessagePayload { + message: cpex_core::cmf::Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![], + channel: None, + }, + }; + let plugin = EnvSandboxDemoPlugin; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + + assert!(result.continue_processing, "expected ALLOW — no args to act on"); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/env_test.rs b/crates/cpex-wasm-plugin/src/examples/env_test.rs new file mode 100644 index 00000000..bbb7d491 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/env_test.rs @@ -0,0 +1,76 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/env_test.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct EnvTestPlugin; + +impl Default for EnvTestPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for EnvTestPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "env-test".to_string(), + kind: "wasm://env-test.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for EnvTestPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Try to read HOME (should be hidden unless explicitly allowed) + let home = std::env::var("HOME").unwrap_or_default(); + ctx.set_local("env_HOME", serde_json::json!(home)); + + // Try to read PATH (should be hidden unless explicitly allowed) + let path = std::env::var("PATH").unwrap_or_default(); + ctx.set_local("env_PATH", serde_json::json!(path)); + + // Try to read a test variable that we explicitly allow in the policy + let allowed = std::env::var("CPEX_TEST_ALLOWED").unwrap_or_default(); + ctx.set_local("env_CPEX_TEST_ALLOWED", serde_json::json!(allowed)); + + // Try to read a secret that should never be visible + let secret = std::env::var("SECRET_API_KEY").unwrap_or_default(); + ctx.set_local("env_SECRET_API_KEY", serde_json::json!(secret)); + + cpex_log!(info, "env check: HOME='{}' PATH='{}' ALLOWED='{}' SECRET='{}'", + home, path, allowed, secret); + + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/fs_sandbox_demo.rs b/crates/cpex-wasm-plugin/src/examples/fs_sandbox_demo.rs new file mode 100644 index 00000000..072c1e6f --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/fs_sandbox_demo.rs @@ -0,0 +1,309 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/fs_sandbox_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// FsSandboxDemoPlugin — demonstrates WASI filesystem sandbox enforcement. +// +// Reads two arguments from the ToolCall payload: +// "operation" — one of: read, write, create_dir, list_dir, delete +// "path" — target path to operate on (relative to the preopened dir) +// +// Attempts the operation using std::fs. If WASI denies it (the operation +// exceeds the policy for that preopened dir), the error becomes a deny +// violation. If it succeeds, the plugin allows with the result in ctx. + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, ContentPart, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct FsSandboxDemoPlugin; + +impl Default for FsSandboxDemoPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for FsSandboxDemoPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "fs-sandbox-demo".to_string(), + kind: "wasm://fs-sandbox-demo.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for FsSandboxDemoPlugin { + async fn handle( + &self, + payload: &MessagePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Extract operation and path from the first ToolCall in the message. + let (operation, path) = match extract_args(payload) { + Some(args) => args, + None => { + cpex_log!(warn, "[fs-sandbox-demo] no tool call with operation+path found — allow"); + return PluginResult::allow(); + } + }; + + cpex_log!(info, "[fs-sandbox-demo] operation='{}' path='{}'", operation, path); + + let result = match operation.as_str() { + "read" => attempt_read(&path), + "write" => attempt_write(&path), + "create_dir" => attempt_create_dir(&path), + "list_dir" => attempt_list_dir(&path), + "delete" => attempt_delete(&path), + other => { + return PluginResult::deny(PluginViolation::new( + "unknown_operation", + &format!( + "unknown operation '{}'; valid values: read, write, create_dir, list_dir, delete", + other + ), + )); + } + }; + + match result { + Ok(detail) => { + cpex_log!(info, "[fs-sandbox-demo] ALLOW: {} on '{}' — {}", operation, path, detail); + ctx.set_local("fs_operation", serde_json::json!(operation)); + ctx.set_local("fs_path", serde_json::json!(path)); + ctx.set_local("fs_result", serde_json::json!("allowed")); + ctx.set_local("fs_detail", serde_json::json!(detail)); + PluginResult::allow() + } + Err(e) => { + cpex_log!(warn, "[fs-sandbox-demo] DENY: {} on '{}' — {}", operation, path, e); + ctx.set_local("fs_operation", serde_json::json!(operation)); + ctx.set_local("fs_path", serde_json::json!(path)); + ctx.set_local("fs_result", serde_json::json!("denied")); + ctx.set_local("fs_error", serde_json::json!(e.to_string())); + PluginResult::deny(PluginViolation::new( + "fs_access_denied", + &format!("filesystem operation '{}' on '{}' denied by sandbox: {}", operation, path, e), + )) + } + } + } +} + +fn extract_args(payload: &MessagePayload) -> Option<(String, String)> { + for part in &payload.message.content { + if let ContentPart::ToolCall { content: tc } = part { + let operation = tc.arguments.get("operation")?.as_str()?.to_string(); + let path = tc.arguments.get("path")?.as_str()?.to_string(); + return Some((operation, path)); + } + } + None +} + +fn attempt_read(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + Ok(format!("read {} bytes", content.len())) +} + +fn attempt_write(path: &str) -> Result { + std::fs::write(path, b"fs-sandbox-demo probe\n")?; + Ok("wrote 22 bytes".to_string()) +} + +fn attempt_create_dir(path: &str) -> Result { + std::fs::create_dir_all(path)?; + Ok(format!("created directory '{}'", path)) +} + +fn attempt_list_dir(path: &str) -> Result { + let entries: Vec = std::fs::read_dir(path)? + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + Ok(format!("listed {} entries: {:?}", entries.len(), entries)) +} + +fn attempt_delete(path: &str) -> Result { + let meta = std::fs::metadata(path)?; + if meta.is_dir() { + std::fs::remove_dir_all(path)?; + } else { + std::fs::remove_file(path)?; + } + Ok(format!("deleted '{}'", path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::cmf::{ContentPart, Message, Role, ToolCall}; + use cpex_core::cmf::constants::SCHEMA_VERSION; + use cpex_core::hooks::trait_def::HookHandler; + use std::collections::HashMap; + + fn make_payload(operation: &str, path: &str) -> MessagePayload { + let mut arguments = HashMap::new(); + arguments.insert("operation".to_string(), serde_json::json!(operation)); + arguments.insert("path".to_string(), serde_json::json!(path)); + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_demo".into(), + name: "fs_sandbox_demo".into(), + arguments, + namespace: None, + }, + }], + channel: None, + }, + } + } + + #[tokio::test] + async fn test_read_existing_file_is_allowed() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test.txt"); + std::fs::write(&file_path, "hello").unwrap(); + + let plugin = FsSandboxDemoPlugin; + let payload = make_payload("read", file_path.to_str().unwrap()); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + + assert!(result.continue_processing, "expected ALLOW"); + assert_eq!(ctx.get_local("fs_result").unwrap(), "allowed"); + } + + #[tokio::test] + async fn test_read_nonexistent_file_is_denied() { + let plugin = FsSandboxDemoPlugin; + let payload = make_payload("read", "/nonexistent_xyz/no_such_file.txt"); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + + assert!(!result.continue_processing, "expected DENY"); + assert_eq!(result.violation.as_ref().unwrap().code, "fs_access_denied"); + assert_eq!(ctx.get_local("fs_result").unwrap(), "denied"); + } + + #[tokio::test] + async fn test_write_to_allowed_path_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("output.txt"); + + let plugin = FsSandboxDemoPlugin; + let payload = make_payload("write", file_path.to_str().unwrap()); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + + assert!(result.continue_processing, "expected ALLOW"); + assert_eq!(ctx.get_local("fs_result").unwrap(), "allowed"); + } + + #[tokio::test] + async fn test_list_dir_allowed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "").unwrap(); + std::fs::write(dir.path().join("b.txt"), "").unwrap(); + + let plugin = FsSandboxDemoPlugin; + let payload = make_payload("list_dir", dir.path().to_str().unwrap()); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + + assert!(result.continue_processing, "expected ALLOW"); + assert_eq!(ctx.get_local("fs_result").unwrap(), "allowed"); + } + + #[tokio::test] + async fn test_unknown_operation_is_denied() { + let plugin = FsSandboxDemoPlugin; + let payload = make_payload("execute", "/tmp/something"); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + + assert!(!result.continue_processing, "expected DENY"); + assert_eq!(result.violation.as_ref().unwrap().code, "unknown_operation"); + } + + #[tokio::test] + async fn test_missing_args_passes_through() { + let payload = MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![], + channel: None, + }, + }; + let plugin = FsSandboxDemoPlugin; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + + assert!(result.continue_processing, "expected ALLOW — no args to act on"); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/fs_test.rs b/crates/cpex-wasm-plugin/src/examples/fs_test.rs new file mode 100644 index 00000000..e53ed3f5 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/fs_test.rs @@ -0,0 +1,76 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/fs_test.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct FsTestPlugin; + +impl Default for FsTestPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for FsTestPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "fs-test".to_string(), + kind: "wasm://fs-test.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for FsTestPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Try to read /etc/passwd (should fail if not in allowed_filesystem) + let target_path = "/etc/passwd"; + cpex_log!(info, "attempting to read '{}'", target_path); + + match std::fs::read_to_string(target_path) { + Ok(content) => { + // If we could read it, report that in context (test will check this) + ctx.set_local("fs_read_success", serde_json::json!(true)); + ctx.set_local("fs_read_length", serde_json::json!(content.len())); + cpex_log!(warn, "successfully read '{}' ({} bytes) — sandbox escape!", target_path, content.len()); + PluginResult::allow() + } + Err(e) => { + // Expected: access denied + ctx.set_local("fs_read_success", serde_json::json!(false)); + ctx.set_local("fs_read_error", serde_json::json!(e.to_string())); + cpex_log!(info, "read '{}' denied: {} — sandbox working correctly", target_path, e); + PluginResult::allow() + } + } + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/header_injector.rs b/crates/cpex-wasm-plugin/src/examples/header_injector.rs new file mode 100644 index 00000000..b6ca2bf5 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/header_injector.rs @@ -0,0 +1,139 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/header_injector.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::guarded::Guarded; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct HeaderInjectorPlugin; + +impl Default for HeaderInjectorPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for HeaderInjectorPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "header-injector".to_string(), + kind: "wasm://header-injector.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for HeaderInjectorPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!(debug, "processing hook, http_headers={}", + extensions.http.as_ref().map(|h| h.request_headers.len()).unwrap_or(0)); + + let mut modified = extensions.cow_copy(); + + if let Some(ref mut sec) = modified.security { + sec.add_label("PROCESSED"); + } + + let mut http = extensions + .http + .as_ref() + .map(|h| (**h).clone()) + .unwrap_or_default(); + http.set_header("X-Processed-By", "header-injector"); + modified.http = Some(Guarded::new(http)); + + cpex_log!(info, "injected header 'X-Processed-By' and label 'PROCESSED'"); + + PluginResult::modify_extensions(modified) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use cpex_core::cmf::constants::SCHEMA_VERSION; + use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall}; + use cpex_core::context::PluginContext; + use cpex_core::extensions::container::Extensions; + use cpex_core::extensions::http::HttpExtension; + use cpex_core::extensions::security::SecurityExtension; + use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; + + use super::HeaderInjectorPlugin; + + fn tool_call_payload(name: &str) -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: format!("tc_{}", name), + name: name.into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } + } + + #[tokio::test] + async fn test_injects_header_and_label() { + let mut sec = SecurityExtension::default(); + sec.add_label("PII"); + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer token"); + let ext = Extensions { + security: Some(Arc::new(sec)), + http: Some(Arc::new(http)), + ..Default::default() + }; + let payload = tool_call_payload("fetch-data"); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &HeaderInjectorPlugin, &payload, &ext, &mut ctx, + ).await; + assert!(result.continue_processing, "expected ALLOW"); + assert!(result.modified_extensions.is_some(), "expected modified extensions"); + let modified = result.modified_extensions.as_ref().unwrap(); + assert!(modified.security.as_ref().unwrap().has_label("PROCESSED")); + assert!(modified.security.as_ref().unwrap().has_label("PII")); + let h = modified.http.as_ref().unwrap().read(); + assert_eq!( + h.request_headers.get("X-Processed-By").map(|s| s.as_str()), + Some("header-injector") + ); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/identity_checker.rs b/crates/cpex-wasm-plugin/src/examples/identity_checker.rs new file mode 100644 index 00000000..4faf9a43 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/identity_checker.rs @@ -0,0 +1,292 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/identity_checker.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// IdentityCheckerPlugin — the bundled WASM plugin implementation. +// +// Implements HookHandler using the same trait that a native plugin +// would implement. No WIT types here — conversions are handled by the SDK. + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::security::{SubjectExtension, SubjectType}; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::identity::{IdentityHook, IdentityPayload}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct IdentityCheckerPlugin; + +impl Default for IdentityCheckerPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for IdentityCheckerPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "identity-checker".to_string(), + kind: "wasm://plugin.wasm".to_string(), + hooks: vec!["cmf".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for IdentityCheckerPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut 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"); + + if let Some(ref security) = extensions.security { + if let Some(ref subject) = security.subject { + cpex_log!(info, "POST-INVOKE: result from '{}' authorized for subject={:?}", tool_name, subject.id); + } + } + } else { + let tool_name = payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + + if let Some(ref security) = extensions.security { + if let Some(ref subject) = security.subject { + cpex_log!(debug, "PRE-INVOKE '{}': subject={:?} roles={:?}", + tool_name, subject.id, subject.roles.iter().collect::>()); + + if security.has_label("PII") && !subject.roles.contains("hr_admin") { + cpex_log!(warn, "PRE-INVOKE '{}': DENIED — missing hr_admin role for PII", tool_name); + return PluginResult::deny(PluginViolation::new( + "insufficient_role", + &format!( + "Tool '{}' requires 'hr_admin' role for PII data", + tool_name + ), + )); + } + } + } + cpex_log!(debug, "PRE-INVOKE '{}': ALLOWED", tool_name); + } + + PluginResult::allow() + } +} + +impl HookHandler for IdentityCheckerPlugin { + /// identity_resolve via the custom payload path. The raw token is + /// `#[serde(skip)]` and never reaches the sandbox, so this resolves + /// the subject from the request headers instead. + async fn handle( + &self, + payload: &IdentityPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.subject.is_some() { + cpex_log!(debug, "IDENTITY: subject already resolved"); + return PluginResult::allow(); + } + + let Some(user_id) = payload.headers().get("x-user-id") else { + cpex_log!(debug, "IDENTITY: no x-user-id header — passing through"); + return PluginResult::allow(); + }; + + cpex_log!(info, "IDENTITY: resolved subject '{}' from header", user_id); + let mut resolved = payload.clone(); + resolved.subject = Some(SubjectExtension { + id: Some(user_id.clone()), + subject_type: Some(SubjectType::User), + ..Default::default() + }); + PluginResult::modify_payload(resolved) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::collections::HashMap; + + use cpex_core::cmf::constants::SCHEMA_VERSION; + use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall}; + use cpex_core::context::PluginContext; + use cpex_core::extensions::container::Extensions; + use cpex_core::extensions::security::{SecurityExtension, SubjectExtension, SubjectType}; + use cpex_core::hooks::payload::PluginPayload; + use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; + use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource}; + + use super::IdentityCheckerPlugin; + + fn tool_call_payload(name: &str) -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: format!("tc_{}", name), + name: name.into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } + } + + fn ext_with_security(f: impl FnOnce(&mut SecurityExtension)) -> Extensions { + let mut sec = SecurityExtension::default(); + f(&mut sec); + Extensions { security: Some(Arc::new(sec)), ..Default::default() } + } + + fn assert_denied(result: &PluginResult

) { + assert!(!result.continue_processing, "expected DENY, got ALLOW"); + assert!(result.violation.is_some(), "denied but no violation"); + } + + fn assert_allowed(result: &PluginResult

) { + assert!(result.continue_processing, "expected ALLOW, got DENY"); + } + + #[tokio::test] + async fn test_denies_pii_access_without_hr_admin_role() { + let ext = ext_with_security(|s| { + s.add_label("PII"); + s.subject = Some(SubjectExtension { + id: Some("bob".into()), + subject_type: Some(SubjectType::User), + roles: ["viewer".to_string()].into(), + ..Default::default() + }); + }); + let payload = tool_call_payload("get_compensation"); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + assert_denied(&result); + } + + #[tokio::test] + async fn test_allows_pii_access_with_hr_admin_role() { + let ext = ext_with_security(|s| { + s.add_label("PII"); + s.subject = Some(SubjectExtension { + id: Some("alice".into()), + subject_type: Some(SubjectType::User), + roles: ["hr_admin".to_string()].into(), + ..Default::default() + }); + }); + let payload = tool_call_payload("get_compensation"); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + assert_allowed(&result); + } + + #[tokio::test] + async fn test_allows_non_pii_without_role() { + let ext = ext_with_security(|s| s.add_label("PUBLIC")); + let payload = tool_call_payload("get_weather"); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + assert_allowed(&result); + } + + #[tokio::test] + async fn test_identity_resolves_subject_from_header() { + let mut headers = HashMap::new(); + headers.insert("x-user-id".to_string(), "alice".to_string()); + let payload = IdentityPayload::new("", TokenSource::Bearer).with_headers(headers); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + assert_allowed(&result); + assert!(result.modified_payload.is_some(), "expected modified payload"); + let subject = result.modified_payload.as_ref().unwrap() + .subject.as_ref().expect("subject should be resolved"); + assert_eq!(subject.id.as_deref(), Some("alice")); + assert_eq!(subject.subject_type, Some(SubjectType::User)); + } + + #[tokio::test] + async fn test_identity_passes_through_without_header() { + let payload = IdentityPayload::new("", TokenSource::Bearer); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + assert_allowed(&result); + assert!(result.modified_payload.is_none()); + } + + #[tokio::test] + async fn test_identity_skips_when_subject_already_resolved() { + let mut headers = HashMap::new(); + headers.insert("x-user-id".to_string(), "bob".to_string()); + let mut payload = IdentityPayload::new("", TokenSource::Bearer).with_headers(headers); + payload.subject = Some(SubjectExtension { + id: Some("existing-user".into()), + subject_type: Some(SubjectType::User), + ..Default::default() + }); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + assert_allowed(&result); + assert!(result.modified_payload.is_none()); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/mod.rs b/crates/cpex-wasm-plugin/src/examples/mod.rs new file mode 100644 index 00000000..88cf0757 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/mod.rs @@ -0,0 +1,60 @@ +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Reference demo plugins — feature-gated. +// Browse these for patterns; edit src/plugin.rs for your own plugin. +// +// Build a demo: make build-demo DEMO=noop +// Build all: make build-demos + +#[cfg(feature = "identity-checker")] +pub mod identity_checker; + +#[cfg(feature = "header-injector")] +pub mod header_injector; + +#[cfg(feature = "audit-logger")] +pub mod audit_logger; + +#[cfg(feature = "token-attenuator")] +pub mod token_attenuator; + +#[cfg(feature = "noop")] +pub mod noop; + +#[cfg(feature = "fs-test")] +pub mod fs_test; + +#[cfg(feature = "net-test")] +pub mod net_test; + +#[cfg(feature = "env-test")] +pub mod env_test; + +#[cfg(feature = "tool-invoke-checker")] +pub mod tool_invoke_checker; + +#[cfg(feature = "compute-bench")] +pub mod compute_bench; + +#[cfg(feature = "pii-guard")] +pub mod pii_guard; + +#[cfg(feature = "audit-logger-custom")] +pub mod audit_logger_custom; + +#[cfg(feature = "remote-authz")] +pub mod remote_authz; + +#[cfg(feature = "fs-sandbox-demo")] +pub mod fs_sandbox_demo; + +#[cfg(feature = "env-sandbox-demo")] +pub mod env_sandbox_demo; + +#[cfg(feature = "resource-test")] +pub mod resource_test; + +#[cfg(feature = "net-http-test")] +pub mod net_http_test; diff --git a/crates/cpex-wasm-plugin/src/examples/net_http_test.rs b/crates/cpex-wasm-plugin/src/examples/net_http_test.rs new file mode 100644 index 00000000..caf259a9 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/net_http_test.rs @@ -0,0 +1,131 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/net_http_test.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Test plugin for network policy enforcement (port / scheme / method). +// Reads "url" and "method" arguments from the ToolCall and attempts a real +// WASI HTTP outgoing request. The host's WasiHttpHooks intercepts the call +// and either allows or denies it based on the NetworkRule in SandboxPolicy. +// +// The plugin writes "http_result" into local_state: +// "allowed" — request was sent (may or may not have received a response) +// "denied" — WasiHttpHooks returned HttpRequestDenied before the wire + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, ContentPart, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; +use crate::wasi::http::outgoing_handler; +use crate::wasi::http::types::{ + Headers, Method, OutgoingRequest, RequestOptions, Scheme, +}; + +pub struct NetHttpTestPlugin; + +impl Default for NetHttpTestPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for NetHttpTestPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "net-http-test".to_string(), + kind: "wasm://net-http-test.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +fn extract_arg(payload: &MessagePayload, key: &str) -> Option { + payload.message.content.iter().find_map(|part| { + if let ContentPart::ToolCall { content } = part { + content.arguments.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()) + } else { + None + } + }) +} + +impl HookHandler for NetHttpTestPlugin { + async fn handle( + &self, + payload: &MessagePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + let url = extract_arg(payload, "url").unwrap_or_else(|| "https://example.com/".to_string()); + let method_str = extract_arg(payload, "method").unwrap_or_else(|| "GET".to_string()); + + cpex_log!(info, "net-http-test: {} {}", method_str, url); + + // Parse scheme, authority, and path from the URL manually — no std URL parser in WASM. + let (scheme, rest) = if let Some(s) = url.strip_prefix("https://") { + (Scheme::Https, s) + } else if let Some(s) = url.strip_prefix("http://") { + (Scheme::Http, s) + } else { + ctx.set_local("http_result", serde_json::json!("bad_url")); + return PluginResult::allow(); + }; + + let (authority, path_and_query) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + + let method = match method_str.to_uppercase().as_str() { + "GET" => Method::Get, + "POST" => Method::Post, + "PUT" => Method::Put, + "DELETE" => Method::Delete, + "PATCH" => Method::Patch, + other => Method::Other(other.to_string()), + }; + + let headers = Headers::new(); + let req = OutgoingRequest::new(headers); + req.set_method(&method).ok(); + req.set_scheme(Some(&scheme)).ok(); + req.set_authority(Some(authority)).ok(); + req.set_path_with_query(Some(path_and_query)).ok(); + + // send() calls WasiHttpHooks::send_request on the host — this is where + // port/scheme/method enforcement happens. + match outgoing_handler::handle(req, None::) { + Ok(_future_response) => { + ctx.set_local("http_result", serde_json::json!("allowed")); + cpex_log!(info, "net-http-test: request allowed by host policy"); + } + Err(e) => { + let reason = format!("{:?}", e); + ctx.set_local("http_result", serde_json::json!("denied")); + ctx.set_local("http_error", serde_json::json!(reason)); + cpex_log!(info, "net-http-test: request denied — {}", reason); + } + } + + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/net_test.rs b/crates/cpex-wasm-plugin/src/examples/net_test.rs new file mode 100644 index 00000000..e3d369b1 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/net_test.rs @@ -0,0 +1,82 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/net_test.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct NetTestPlugin; + +impl Default for NetTestPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for NetTestPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "net-test".to_string(), + kind: "wasm://net-test.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for NetTestPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!(info, "attempting outbound HTTP to httpbin.org"); + + // Attempt an outbound HTTP request using WASI HTTP + // This uses the raw WASI HTTP types generated by wit-bindgen + // In WASM, we can't use reqwest — we use the WASI outgoing-handler directly + // For simplicity, we just report whether the std::net path is available + // (it won't be in WASM — WASI doesn't expose raw sockets via std::net) + // + // The real network test is at the host level: the NetworkPolicy hook + // intercepts outgoing HTTP requests and denies them if not in the allowlist. + // We test this by checking if the environment suggests network is blocked. + + // Try to resolve a DNS name (tests if network stack is available at all) + match std::net::ToSocketAddrs::to_socket_addrs(&("httpbin.org", 80)) { + Ok(_addrs) => { + ctx.set_local("net_access", serde_json::json!("dns_resolved")); + cpex_log!(warn, "DNS resolution succeeded — unexpected in sandboxed WASM"); + } + Err(e) => { + ctx.set_local("net_access", serde_json::json!("denied")); + ctx.set_local("net_error", serde_json::json!(e.to_string())); + cpex_log!(info, "network access denied: {}", e); + } + } + + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/noop.rs b/crates/cpex-wasm-plugin/src/examples/noop.rs new file mode 100644 index 00000000..21b50c5d --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/noop.rs @@ -0,0 +1,55 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/noop.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +pub struct NoopPlugin; + +impl Default for NoopPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for NoopPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "noop".to_string(), + kind: "wasm://noop.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for NoopPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/pii_guard.rs b/crates/cpex-wasm-plugin/src/examples/pii_guard.rs new file mode 100644 index 00000000..ed1daf5b --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/pii_guard.rs @@ -0,0 +1,151 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/pii_guard.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// PiiGuardPlugin — WASM plugin that blocks access to PII tools without clearance. +// +// Mirrors the native PiiGuard from plugin_demo.rs: checks that pii_clearance +// is set in PluginContext global state before allowing PII-tagged tools. +// Runs as priority 20 in the sequential pipeline. + + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +// --------------------------------------------------------------------------- +// Payload and hook types (same definition as all demo plugins) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + pub tool_name: String, + pub user: String, + pub arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +pub struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// --------------------------------------------------------------------------- +// Plugin implementation +// --------------------------------------------------------------------------- + +pub struct PiiGuardPlugin; + +impl Default for PiiGuardPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for PiiGuardPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "pii-guard".to_string(), + kind: "wasm://pii-guard.wasm".to_string(), + hooks: vec!["tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for PiiGuardPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + let has_clearance = ctx + .get_global("pii_clearance") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !has_clearance { + cpex_log!( + warn, + "[pii-guard] DENIED: user '{}' lacks PII clearance for '{}'", + payload.user, + payload.tool_name + ); + return PluginResult::deny(PluginViolation::new( + "pii_access_denied", + "PII clearance required", + )); + } + + cpex_log!(info, "[pii-guard] OK: user '{}' has PII clearance", payload.user); + PluginResult::allow() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_no_clearance_denied() { + let plugin = PiiGuardPlugin; + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "pii_access_denied"); + } + + #[tokio::test] + async fn test_with_clearance_allowed() { + let plugin = PiiGuardPlugin; + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + ctx.set_global("pii_clearance", serde_json::Value::Bool(true)); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/remote_authz.rs b/crates/cpex-wasm-plugin/src/examples/remote_authz.rs new file mode 100644 index 00000000..2a4e7a5f --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/remote_authz.rs @@ -0,0 +1,216 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/remote_authz.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// RemoteAuthzPlugin — WASM plugin that simulates remote authorization with +// cross-invocation state persistence. +// +// Mirrors the native RemoteAuthz from plugin_demo.rs: maintains a cached ACL +// that persists across invocations via WASM linear memory. On the first call, +// the ACL is "fetched" (simulated) and stored in a module-level static. All +// subsequent calls read from the cached set — demonstrating that the WASM +// sandbox's Store (and linear memory) is preserved across invocations. +// +// This is the WASM equivalent of the native plugin's `initialize()` + RwLock +// pattern: the SandboxManager keeps the Store alive, so static variables in +// the WASM module survive between calls. + + +use std::collections::HashSet; +use std::sync::OnceLock; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +// --------------------------------------------------------------------------- +// Payload and hook types (same definition as all demo plugins) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + pub tool_name: String, + pub user: String, + pub arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +pub struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// --------------------------------------------------------------------------- +// Persistent ACL — survives across invocations via WASM linear memory. +// +// The SandboxManager creates the Store once at plugin load time and reuses it +// for every invoke() call (only fuel and epoch are reset). Module-level statics +// in the WASM binary persist across calls, just like an in-memory cache would +// in a long-running native process. +// --------------------------------------------------------------------------- + +static ACL: OnceLock> = OnceLock::new(); + +fn get_or_init_acl() -> &'static HashSet { + ACL.get_or_init(|| { + cpex_log!(info, "[remote-authz] initializing ACL (first invocation — simulating remote fetch)"); + let mut acl = HashSet::new(); + acl.insert("alice".to_string()); + acl.insert("bob".to_string()); + cpex_log!(info, "[remote-authz] ACL cached ({} users)", acl.len()); + acl + }) +} + +// --------------------------------------------------------------------------- +// Plugin implementation +// --------------------------------------------------------------------------- + +pub struct RemoteAuthzPlugin; + +impl Default for RemoteAuthzPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: OnceLock = OnceLock::new(); + +#[async_trait] +impl Plugin for RemoteAuthzPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "remote-authz".to_string(), + kind: "wasm://remote-authz.wasm".to_string(), + hooks: vec!["tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for RemoteAuthzPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let acl = get_or_init_acl(); + + if acl.contains(&payload.user) { + cpex_log!( + info, + "[remote-authz] OK (ACL hit): user '{}' allowed", + payload.user + ); + return PluginResult::allow(); + } + + cpex_log!( + warn, + "[remote-authz] DENIED (ACL miss): user '{}' not in remote ACL", + payload.user + ); + PluginResult::deny(PluginViolation::new( + "remote_authz_denied", + format!("User '{}' not in remote ACL", payload.user), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_allowed_user_passes() { + let plugin = RemoteAuthzPlugin; + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "alice".into(), + arguments: "dataset=sales".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_denied_user_blocked() { + let plugin = RemoteAuthzPlugin; + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "charlie".into(), + arguments: "dataset=sales".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "remote_authz_denied"); + } + + #[tokio::test] + async fn test_acl_persists_across_calls() { + let plugin = RemoteAuthzPlugin; + let ext = Extensions::default(); + + // First call — initializes ACL + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "alice".into(), + arguments: "".into(), + }; + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + + // Second call — reads from cached ACL (no re-init) + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "bob".into(), + arguments: "".into(), + }; + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/resource_test.rs b/crates/cpex-wasm-plugin/src/examples/resource_test.rs new file mode 100644 index 00000000..d485047d --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/resource_test.rs @@ -0,0 +1,116 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/resource_test.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Test plugin for resource limit enforcement. +// Reads the "resource_test_mode" arg from the first ToolCall and exercises +// the corresponding limit so the host can verify it traps correctly. +// +// Modes (passed as tool call argument "mode"): +// "burn_fuel" — tight loop consuming instructions until fuel is exhausted +// "infinite_loop" — spins forever until the epoch deadline fires +// "alloc_memory" — allocates 512 MB of heap in chunks until OOM + + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, ContentPart, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct ResourceTestPlugin; + +impl Default for ResourceTestPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for ResourceTestPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "resource-test".to_string(), + kind: "wasm://resource-test.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +fn extract_mode(payload: &MessagePayload) -> String { + payload + .message + .content + .iter() + .find_map(|part| { + if let ContentPart::ToolCall { content } = part { + content.arguments.get("mode").and_then(|v| v.as_str()).map(|s| s.to_string()) + } else { + None + } + }) + .unwrap_or_default() +} + +impl HookHandler for ResourceTestPlugin { + async fn handle( + &self, + payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let mode = extract_mode(payload); + cpex_log!(info, "resource-test mode: {}", mode); + + match mode.as_str() { + "burn_fuel" => { + // Tight arithmetic loop — burns through fuel quickly. + // Will never finish if fuel limit is generous; with a tiny + // limit (e.g. max_fuel: 10000) this traps almost immediately. + let mut x: u64 = 1; + loop { + x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + if x == 0 { break; } // unreachable in practice + } + PluginResult::allow() + } + "infinite_loop" => { + // Spins forever — relies on the epoch deadline to interrupt. + let mut i: u64 = 0; + loop { + i = i.wrapping_add(1); + if i == 0 { break; } // unreachable in practice + } + PluginResult::allow() + } + "alloc_memory" => { + // Allocates 1 MB chunks until OOM trap fires. + let mut blobs: Vec> = Vec::new(); + loop { + blobs.push(vec![0u8; 1024 * 1024]); + } + } + _ => { + cpex_log!(warn, "unknown resource-test mode '{}' — returning allow", mode); + PluginResult::allow() + } + } + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/token_attenuator.rs b/crates/cpex-wasm-plugin/src/examples/token_attenuator.rs new file mode 100644 index 00000000..fc6489cc --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/token_attenuator.rs @@ -0,0 +1,162 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/token_attenuator.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + + +use async_trait::async_trait; +use chrono::Utc; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{DelegationPayload, TargetType, TokenDelegateHook}; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken}; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct TokenAttenuatorPlugin; + +impl Default for TokenAttenuatorPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for TokenAttenuatorPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "token-attenuator".to_string(), + kind: "wasm://token-attenuator.wasm".to_string(), + hooks: vec!["token.delegate".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for TokenAttenuatorPlugin { + async fn handle( + &self, + payload: &DelegationPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let target_name = payload.target_name(); + let target_type = payload.target_type(); + + cpex_log!(info, "DELEGATE: minting token for target='{}' type={:?}", target_name, target_type); + + // Only handle Tool targets — pass through for other types + if *target_type != TargetType::Tool { + cpex_log!(debug, "DELEGATE: not a tool target, passing through"); + return PluginResult::allow(); + } + + // Mint a scoped token for the target tool + let mut resolved = payload.clone(); + resolved.delegated_token = Some(RawDelegatedToken { + token: zeroize::Zeroizing::new(String::new()), + outbound_header: "Authorization".to_string(), + audience: payload + .target_audience() + .unwrap_or(target_name) + .to_string(), + scopes: payload + .required_permissions() + .iter() + .map(|s| s.to_string()) + .collect(), + expires_at: Utc::now() + chrono::Duration::minutes(5), + }); + resolved.delegation_mode = Some(DelegationMode::OnBehalfOfUser); + resolved.minted_at = Some(Utc::now()); + resolved.metadata.insert( + "minter".to_string(), + serde_json::json!("token-attenuator-wasm"), + ); + + cpex_log!(info, "DELEGATE: token minted for audience='{}'", + resolved.delegated_token.as_ref().unwrap().audience); + + PluginResult::modify_payload(resolved) + } +} + +#[cfg(test)] +mod tests { + use cpex_core::context::PluginContext; + use cpex_core::delegation::{DelegationPayload, TargetType, TokenDelegateHook}; + use cpex_core::extensions::container::Extensions; + use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; + + use super::TokenAttenuatorPlugin; + + #[tokio::test] + async fn test_mints_token_for_tool_target() { + let payload = DelegationPayload::new("", "get_compensation") + .with_target_type(TargetType::Tool) + .with_target_audience("hr-service.internal") + .with_required_permissions(vec!["read_compensation".into()]); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &TokenAttenuatorPlugin, &payload, &ext, &mut ctx, + ).await; + assert!(result.continue_processing, "expected ALLOW"); + assert!(result.modified_payload.is_some(), "expected modified payload"); + let modified = result.modified_payload.as_ref().unwrap(); + let token = modified.delegated_token.as_ref().expect("should mint token"); + assert_eq!(token.audience, "hr-service.internal"); + assert_eq!(token.scopes, vec!["read_compensation"]); + assert_eq!(token.outbound_header, "Authorization"); + assert!(modified.minted_at.is_some()); + assert_eq!( + modified.metadata.get("minter").and_then(|v| v.as_str()), + Some("token-attenuator-wasm") + ); + } + + #[tokio::test] + async fn test_passes_through_non_tool_targets() { + let payload = DelegationPayload::new("", "agent-downstream") + .with_target_type(TargetType::Agent); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &TokenAttenuatorPlugin, &payload, &ext, &mut ctx, + ).await; + assert!(result.continue_processing, "expected ALLOW"); + assert!(result.modified_payload.is_none()); + } + + #[tokio::test] + async fn test_uses_target_name_as_audience_when_no_explicit_audience() { + let payload = DelegationPayload::new("", "fetch-records") + .with_target_type(TargetType::Tool); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult<_> = + >::handle( + &TokenAttenuatorPlugin, &payload, &ext, &mut ctx, + ).await; + assert!(result.modified_payload.is_some(), "expected modified payload"); + let token = result.modified_payload.as_ref().unwrap() + .delegated_token.as_ref().unwrap(); + assert_eq!(token.audience, "fetch-records"); + } +} diff --git a/crates/cpex-wasm-plugin/src/examples/tool_invoke_checker.rs b/crates/cpex-wasm-plugin/src/examples/tool_invoke_checker.rs new file mode 100644 index 00000000..808bbbfd --- /dev/null +++ b/crates/cpex-wasm-plugin/src/examples/tool_invoke_checker.rs @@ -0,0 +1,179 @@ +// Location: ./crates/cpex-wasm-plugin/src/examples/tool_invoke_checker.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// ToolInvokeCheckerPlugin — WASM identity resolver. +// +// Mirrors the native IdentityResolver from plugin_demo.rs: checks that a +// user identity is present in the custom ToolInvokePayload. Runs as the +// first plugin in the sequential pipeline (priority 10). + + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +// --------------------------------------------------------------------------- +// Payload and hook types — shared definition used by all 4 demo plugins. +// Each WASM binary carries its own copy (same struct, same discriminator). +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + pub tool_name: String, + pub user: String, + pub arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +pub struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +pub struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} + +// --------------------------------------------------------------------------- +// Plugin implementation — identity check only +// --------------------------------------------------------------------------- + +pub struct ToolInvokeCheckerPlugin; + +impl Default for ToolInvokeCheckerPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for ToolInvokeCheckerPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "identity-resolver".to_string(), + kind: "wasm://tool-invoke-checker.wasm".to_string(), + hooks: vec!["tool_pre_invoke".to_string(), "tool_post_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for ToolInvokeCheckerPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.user.is_empty() { + cpex_log!(warn, "[identity-resolver] DENIED: no user identity"); + return PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )); + } + cpex_log!(info, "[identity-resolver] OK: user '{}' identified", payload.user); + PluginResult::allow() + } +} + +impl HookHandler for ToolInvokeCheckerPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!( + info, + "[identity-resolver] post-invoke: user '{}' completed '{}'", + payload.user, + payload.tool_name + ); + PluginResult::allow() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::hooks::payload::WasmSerializablePayload; + use cpex_core::hooks::trait_def::HookHandler; + + #[test] + fn test_payload_serde_roundtrip() { + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let bytes = payload.to_wasm_bytes().unwrap(); + let restored = ToolInvokePayload::from_wasm_bytes(&bytes).unwrap(); + assert_eq!(restored.tool_name, "get_compensation"); + assert_eq!(restored.user, "alice"); + assert_eq!(restored.arguments, "employee_id=42"); + } + + #[tokio::test] + async fn test_no_user_denied() { + let plugin = ToolInvokeCheckerPlugin; + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "".into(), + arguments: "".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "no_identity"); + } + + #[tokio::test] + async fn test_user_present_allowed() { + let plugin = ToolInvokeCheckerPlugin; + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "alice".into(), + arguments: "".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } +} diff --git a/crates/cpex-wasm-plugin/src/lib.rs b/crates/cpex-wasm-plugin/src/lib.rs new file mode 100644 index 00000000..fafaaaef --- /dev/null +++ b/crates/cpex-wasm-plugin/src/lib.rs @@ -0,0 +1,560 @@ +// Location: ./crates/cpex-wasm-plugin/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// ============================================================================ +// CPEX WASM Plugin SDK — lib.rs +// ============================================================================ +// +// This file is the SDK glue that connects your plugin logic (src/plugin.rs) to +// the WASM component model. You should NOT need to edit this file. +// +// What this file does: +// +// 1. WIT BINDINGS (wit_bindgen::generate!) +// Reads wit/world.wit at compile time and generates: +// - Guest trait: the interface the host calls into +// - export! macro: produces #[no_mangle] ABI entry points +// - WIT types: HookPayload, HookResult, Extensions, PluginContext +// (flat/serialized types for the WASM boundary — NOT cpex-core types) +// +// 2. PRELUDE (pub mod prelude) +// Re-exports everything a plugin author needs in one import: +// use crate::prelude::*; +// +// 3. CONVERSIONS (pub mod conversions) +// Bridges WIT types ↔ cpex-core native types. Your plugin never touches +// WIT types — the macro handles translation automatically. +// +// 4. register_wasm_plugin! MACRO +// Generates the Guest impl that: +// a. Receives WIT types from the host +// b. Converts them to cpex-core native types +// c. Routes to YOUR HookHandler::handle() method +// d. Converts your PluginResult back to a WIT HookResult +// e. Calls export!() to produce the WASM export symbol +// +// 5. PLUGIN REGISTRATION (bottom of this file) +// Wires src/plugin.rs as the active plugin. When no demo feature flag is +// set, your plugin is what gets compiled into the .wasm binary. +// +// Quickstart: +// 1. Edit src/plugin.rs +// 2. Run: make build +// 3. Output: target/wasm32-wasip2/release/cpex_wasm_plugin.wasm +// +// See src/examples/ for reference demo implementations. + +pub mod conversions; +pub mod plugin; +pub mod examples; + +// --------------------------------------------------------------------------- +// WIT bindings — generated from wit/world.wit +// --------------------------------------------------------------------------- + +wit_bindgen::generate!({ + path: "wit", + world: "plugin", + generate_all, +}); + +// --------------------------------------------------------------------------- +// Prelude — one import for everything a plugin author needs +// --------------------------------------------------------------------------- + +/// Re-exports every type and macro a plugin author needs. +/// +/// Add `use cpex_wasm_plugin::prelude::*;` to your plugin file and you have +/// everything: traits, payload types, context, extensions, and macros. +pub mod prelude { + // Core traits + pub use async_trait::async_trait; + pub use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; + pub use cpex_core::plugin::{Plugin, PluginConfig}; + pub use cpex_core::context::PluginContext; + pub use cpex_core::extensions::container::Extensions; + pub use cpex_core::error::{PluginError, PluginViolation}; + + // CMF hook + payload + pub use cpex_core::cmf::{CmfHook, MessagePayload}; + + // Identity hook + payload + pub use cpex_core::identity::{IdentityHook, IdentityPayload}; + + // Token delegation hook + payload + pub use cpex_core::delegation::{TokenDelegateHook, DelegationPayload}; + + // Macros — SDK + pub use crate::{cpex_log, register_wasm_plugin}; + // Macros — custom hook/payload definition + pub use cpex_core::{define_hook, impl_plugin_payload, impl_wasm_payload}; +} + +// --------------------------------------------------------------------------- +// Structured host logging +// --------------------------------------------------------------------------- + +/// Log level for host-side structured logging. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +/// Send a structured log message to the host's tracing infrastructure. +/// +/// The host routes this to its `tracing` subscriber with the plugin name +/// attached as a span field. Use this instead of `eprintln!` for production +/// logging. +pub fn host_log(level: LogLevel, message: &str) { + #[cfg(test)] + { + eprintln!("[{:?}] {}", level, message); + return; + } + + #[cfg(not(test))] + { + use crate::cpex::plugin::host_logging; + let wit_level = match level { + LogLevel::Trace => host_logging::LogLevel::Trace, + LogLevel::Debug => host_logging::LogLevel::Debug, + LogLevel::Info => host_logging::LogLevel::Info, + LogLevel::Warn => host_logging::LogLevel::Warn, + LogLevel::Error => host_logging::LogLevel::Error, + }; + host_logging::log(wit_level, message); + } +} + +/// Convenience macro for structured host logging with format arguments. +/// +/// # Example +/// ```ignore +/// cpex_log!(info, "processed {} items in {}ms", count, elapsed); +/// cpex_log!(warn, "payload field missing, using default"); +/// ``` +#[macro_export] +macro_rules! cpex_log { + (trace, $($arg:tt)*) => { $crate::host_log($crate::LogLevel::Trace, &format!($($arg)*)) }; + (debug, $($arg:tt)*) => { $crate::host_log($crate::LogLevel::Debug, &format!($($arg)*)) }; + (info, $($arg:tt)*) => { $crate::host_log($crate::LogLevel::Info, &format!($($arg)*)) }; + (warn, $($arg:tt)*) => { $crate::host_log($crate::LogLevel::Warn, &format!($($arg)*)) }; + (error, $($arg:tt)*) => { $crate::host_log($crate::LogLevel::Error, &format!($($arg)*)) }; +} + +// --------------------------------------------------------------------------- +// register_wasm_plugin! — the core macro +// +// Generates a complete `Guest` impl that: +// 1. Receives WIT types from the host (hook-name, payload, extensions, ctx) +// 2. Converts WIT → cpex-core native types +// 3. Routes to the matching HookHandler based on the payload's concrete type +// 4. Converts PluginResult → WIT HookResult (with context writeback) +// +// Usage: +// register_wasm_plugin!(MyPlugin, [CmfHook]); +// register_wasm_plugin!(MyPlugin, [CmfHook, IdentityHook]); +// register_wasm_plugin!(MyPlugin, [TokenDelegateHook]); +// +// Rules: +// - `MyPlugin` must implement `Default` and `HookHandler` for every H listed. +// - Each H's `Payload` type must implement `WasmSerializablePayload`. +// - Payloads that match no listed hook → allow() (same as a native plugin not +// registered for that hook). +// - Payloads that match a listed hook but fail to decode → deny() with +// code "wasm_payload_decode_error" (failing open would silently skip checks). +// --------------------------------------------------------------------------- + +/// Register a plugin struct as the WASM guest entry point. +/// +/// # Example +/// ```ignore +/// use cpex_wasm_plugin::prelude::*; +/// +/// struct MyPlugin; +/// impl Default for MyPlugin { fn default() -> Self { Self } } +/// +/// // impl Plugin + HookHandler for MyPlugin { ... } +/// +/// register_wasm_plugin!(MyPlugin, [CmfHook]); +/// ``` +#[macro_export] +macro_rules! register_wasm_plugin { + ($plugin_ty:ty, [$($hook_ty:ty),+ $(,)?]) => { + struct _WasmGuestImpl; + + impl Guest for _WasmGuestImpl { + fn handle_hook( + hook_name: String, + payload: HookPayload, + extensions: Extensions, + ctx: PluginContext, + ) -> HookResult { + use cpex_core::hooks::payload::WasmSerializablePayload; + use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef}; + + let native_ext = $crate::wit_extensions_to_native(extensions); + let mut native_ctx = $crate::wit_context_to_native(ctx); + + match payload { + HookPayload::Cmf(mp) => { + let native_payload = $crate::wit_payload_to_native(mp); + let any: &dyn ::std::any::Any = &native_payload; + $( + if let Some(typed) = + any.downcast_ref::<<$hook_ty as HookTypeDef>::Payload>() + { + let plugin = <$plugin_ty>::default(); + let result = $crate::__block_on( + <$plugin_ty as HookHandler<$hook_ty>>::handle( + &plugin, + typed, + &native_ext, + &mut native_ctx, + ) + ); + return $crate::native_result_to_hook_result_generic( + result, &native_ctx, + ); + } + )+ + eprintln!( + "[WASM] no handler for CMF payload on hook '{}' — allow", + hook_name + ); + $crate::__allow_hook_result(&native_ctx) + } + HookPayload::Identity(ip) => { + let native_payload = $crate::wit_identity_payload_to_native(ip); + let any: &dyn ::std::any::Any = &native_payload; + $( + if let Some(typed) = + any.downcast_ref::<<$hook_ty as HookTypeDef>::Payload>() + { + let plugin = <$plugin_ty>::default(); + let result = $crate::__block_on( + <$plugin_ty as HookHandler<$hook_ty>>::handle( + &plugin, + typed, + &native_ext, + &mut native_ctx, + ) + ); + return $crate::native_result_to_hook_result_generic( + result, &native_ctx, + ); + } + )+ + $crate::__allow_hook_result(&native_ctx) + } + HookPayload::Delegation(dp) => { + let native_payload = $crate::wit_delegation_payload_to_native(dp); + let any: &dyn ::std::any::Any = &native_payload; + $( + if let Some(typed) = + any.downcast_ref::<<$hook_ty as HookTypeDef>::Payload>() + { + let plugin = <$plugin_ty>::default(); + let result = $crate::__block_on( + <$plugin_ty as HookHandler<$hook_ty>>::handle( + &plugin, + typed, + &native_ext, + &mut native_ctx, + ) + ); + return $crate::native_result_to_hook_result_generic( + result, &native_ctx, + ); + } + )+ + $crate::__allow_hook_result(&native_ctx) + } + HookPayload::Custom(gp) => { + $( + if gp.payload_type + == <<$hook_ty as HookTypeDef>::Payload + as WasmSerializablePayload>::payload_type_name() + { + match <<$hook_ty as HookTypeDef>::Payload + as WasmSerializablePayload>::from_wasm_bytes(&gp.payload_data) + { + Ok(typed) => { + let plugin = <$plugin_ty>::default(); + let result = $crate::__block_on( + <$plugin_ty as HookHandler<$hook_ty>>::handle( + &plugin, + &typed, + &native_ext, + &mut native_ctx, + ) + ); + return $crate::native_result_to_hook_result_generic( + result, &native_ctx, + ); + } + Err(e) => { + return $crate::__decode_error_hook_result( + &gp.payload_type, &e.to_string(), &native_ctx, + ); + } + } + } + )+ + eprintln!( + "[WASM] unhandled custom payload '{}' on hook '{}' — allow", + gp.payload_type, hook_name + ); + $crate::__allow_hook_result(&native_ctx) + } + } + } + } + + export!(_WasmGuestImpl); + }; +} + +// --------------------------------------------------------------------------- +// Macro support functions — used by register_wasm_plugin! expansion. +// Public so the expanded macro can call them; not part of the plugin-author API. +// --------------------------------------------------------------------------- + +pub use conversions::{ + native_payload_to_wit, native_result_to_hook_result_generic, + wit_context_to_native, wit_delegation_payload_to_native, + wit_extensions_to_native, wit_identity_payload_to_native, wit_payload_to_native, +}; + +/// Allow-and-continue result for payloads this plugin has no handler for. +pub fn __allow_hook_result(ctx: &cpex_core::context::PluginContext) -> HookResult { + HookResult { + continue_processing: true, + modified_payload: None, + modified_extensions: None, + modified_context: Some(conversions::native_context_to_wit(ctx)), + violation: None, + metadata: None, + } +} + +/// Deny result when a declared payload type fails to decode. +/// Failing open here would silently skip the plugin's check. +pub fn __decode_error_hook_result( + payload_type: &str, + error: &str, + ctx: &cpex_core::context::PluginContext, +) -> HookResult { + eprintln!("[WASM] failed to decode payload '{}': {}", payload_type, error); + HookResult { + continue_processing: false, + modified_payload: None, + modified_extensions: None, + modified_context: Some(conversions::native_context_to_wit(ctx)), + violation: Some(crate::cpex::plugin::types::PluginViolation { + code: "wasm_payload_decode_error".to_string(), + reason: format!("failed to decode payload '{}': {}", payload_type, error), + description: None, + details: "{}".to_string(), + plugin_name: None, + proto_error_code: None, + }), + metadata: None, + } +} + +/// Synchronous async executor for WASM. +/// +/// Futures returned by `HookHandler::handle()` must be driven to completion +/// synchronously. Current handlers await nothing in WASM context, so the +/// future completes on the first poll in practice. A 10,000-iteration cap +/// prevents infinite busy-loops if a future unexpectedly yields. +pub fn __block_on(f: F) -> F::Output { + use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + const MAX_POLLS: u32 = 10_000; + + fn noop(_: *const ()) {} + fn noop_clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + static VTABLE: RawWakerVTable = RawWakerVTable::new(noop_clone, noop, noop, noop); + + let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }; + let mut cx = Context::from_waker(&waker); + let mut pinned = std::pin::pin!(f); + + for _ in 0..MAX_POLLS { + match pinned.as_mut().poll(&mut cx) { + Poll::Ready(val) => return val, + Poll::Pending => continue, + } + } + + panic!("[WASM] executor exceeded {} polls — handler future is not completing", MAX_POLLS); +} + +// --------------------------------------------------------------------------- +// Plugin registration — feature-gated +// +// Demo plugins (src/examples/) — each is feature-gated so only one compiles +// per .wasm binary. Build a demo: make build-demo DEMO= +// +// Your plugin (src/plugin.rs) is registered at the bottom of this file and +// compiles when NO demo feature is active: make build +// --------------------------------------------------------------------------- + +#[cfg(all(feature = "identity-checker", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::identity_checker::IdentityCheckerPlugin, + [cpex_core::cmf::CmfHook, cpex_core::identity::IdentityHook] +); + +#[cfg(all(feature = "header-injector", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::header_injector::HeaderInjectorPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "audit-logger", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::audit_logger::AuditLoggerPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "token-attenuator", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::token_attenuator::TokenAttenuatorPlugin, + [cpex_core::delegation::TokenDelegateHook] +); + +#[cfg(all(feature = "noop", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::noop::NoopPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "fs-test", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::fs_test::FsTestPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "net-test", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::net_test::NetTestPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "env-test", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::env_test::EnvTestPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "tool-invoke-checker", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::tool_invoke_checker::ToolInvokeCheckerPlugin, + [ + examples::tool_invoke_checker::ToolPreInvoke, + examples::tool_invoke_checker::ToolPostInvoke, + ] +); + +#[cfg(all(feature = "pii-guard", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::pii_guard::PiiGuardPlugin, + [examples::pii_guard::ToolPreInvoke] +); + +#[cfg(all(feature = "audit-logger-custom", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::audit_logger_custom::AuditLoggerCustomPlugin, + [ + examples::audit_logger_custom::ToolPreInvoke, + examples::audit_logger_custom::ToolPostInvoke, + ] +); + +#[cfg(all(feature = "remote-authz", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::remote_authz::RemoteAuthzPlugin, + [examples::remote_authz::ToolPreInvoke] +); + +#[cfg(all(feature = "compute-bench", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::compute_bench::ComputeBenchPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "fs-sandbox-demo", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::fs_sandbox_demo::FsSandboxDemoPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "env-sandbox-demo", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::env_sandbox_demo::EnvSandboxDemoPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "resource-test", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::resource_test::ResourceTestPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "net-http-test", not(test), not(feature = "test-demos")))] +register_wasm_plugin!( + examples::net_http_test::NetHttpTestPlugin, + [cpex_core::cmf::CmfHook] +); + +// --------------------------------------------------------------------------- +// YOUR PLUGIN — active when no demo feature is enabled (i.e. `make build`). +// +// This registers src/plugin.rs as the WASM export. If you change the hook type +// in plugin.rs (e.g. from CmfHook to IdentityHook), update the list below too. +// +// What this does at compile time: +// 1. Generates a Guest impl that receives WIT types from the host +// 2. Converts them to cpex-core native types +// 3. Calls YOUR HookHandler::handle() method +// 4. Converts your PluginResult back to a WIT HookResult +// 5. Exports the `handle-hook` function symbol for the WASM component model +// --------------------------------------------------------------------------- + +#[cfg(all( + not(test), + not(feature = "test-demos"), + not(feature = "identity-checker"), + not(feature = "header-injector"), + not(feature = "audit-logger"), + not(feature = "token-attenuator"), + not(feature = "noop"), + not(feature = "fs-test"), + not(feature = "net-test"), + not(feature = "env-test"), + not(feature = "tool-invoke-checker"), + not(feature = "pii-guard"), + not(feature = "audit-logger-custom"), + not(feature = "remote-authz"), + not(feature = "compute-bench"), + not(feature = "fs-sandbox-demo"), + not(feature = "env-sandbox-demo"), + not(feature = "resource-test"), + not(feature = "net-http-test"), +))] +register_wasm_plugin!( + plugin::UserPlugin, + [cpex_core::cmf::CmfHook] // ← change this if you use a different hook type +); + diff --git a/crates/cpex-wasm-plugin/src/plugin.rs b/crates/cpex-wasm-plugin/src/plugin.rs new file mode 100644 index 00000000..ad95ffe8 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugin.rs @@ -0,0 +1,133 @@ +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// +// Your plugin — edit this file and run `make build`. +// +// Steps: +// 1. Rename the struct and update the config below +// 2. Pick your hook type (see table below) +// 3. Write your logic in handle() +// 4. Build: `make build` +// +// Available hook types: +// CmfHook → intercept tool calls/results (event: "cmf.tool_pre_invoke") +// IdentityHook → resolve user identity (event: "identity.resolve") +// TokenDelegateHook → mint scoped outbound credentials (event: "token.delegate") +// Custom → define your own (see src/examples/tool_invoke_checker.rs) +// +// Return values from handle(): +// PluginResult::allow() — pass through unchanged +// PluginResult::deny(violation) — block with a reason +// PluginResult::modify_payload(p) — pass through with modifications + +use crate::prelude::*; +use std::sync::OnceLock; + +// Lazily-initialized plugin metadata. The host never calls config() on the WASM +// guest (it uses config.yaml instead), but the Plugin trait requires it. +static PLUGIN_CONFIG: OnceLock = OnceLock::new(); + +#[derive(Default)] +pub struct UserPlugin; + +#[async_trait] +impl Plugin for UserPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "user-plugin".to_string(), + kind: "wasm://user-plugin.wasm".to_string(), + // Informational only — the host's config.yaml controls actual routing. + // Change this to match your hook type: + // CmfHook → "cmf.tool_pre_invoke" + // IdentityHook → "identity.resolve" + // TokenDelegateHook → "token.delegate" + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + async fn initialize(&self) -> Result<(), Box> { Ok(()) } + async fn shutdown(&self) -> Result<(), Box> { Ok(()) } +} + +// Change CmfHook to your hook type if needed. +// Also update the registration in src/lib.rs (bottom of file) to match. +impl HookHandler for UserPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // TODO: Your plugin logic here + PluginResult::allow() + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── +// Run with: cargo test --lib plugin +// or: make test + +#[cfg(test)] +mod tests { + use cpex_core::cmf::constants::SCHEMA_VERSION; + use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall}; + use cpex_core::context::PluginContext; + use cpex_core::extensions::container::Extensions; + use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; + + use super::UserPlugin; + + fn tool_call_payload(name: &str) -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: format!("tc_{}", name), + name: name.into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } + } + + #[tokio::test] + async fn test_allows_tool_call() { + let payload = tool_call_payload("any_tool"); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &UserPlugin, &payload, &ext, &mut ctx, + ).await; + + assert!(result.continue_processing, "plugin should allow by default"); + assert!(result.violation.is_none()); + } + + #[tokio::test] + async fn test_allows_empty_content() { + let payload = MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::User, + content: vec![], + channel: None, + }, + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &UserPlugin, &payload, &ext, &mut ctx, + ).await; + + assert!(result.continue_processing); + } +} 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..c8498651 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/world.wit @@ -0,0 +1,678 @@ +// 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. +// +// Supports arbitrary payload types via the hook-payload variant: +// - cmf: structured CMF MessagePayload (field-by-field conversion, no full-payload serialization) +// - custom: any serializable payload as JSON bytes with a type discriminator + +package cpex:plugin; + +interface types { + + // --------------------------------------------------------------------------- + // CMF enums: + // --------------------------------------------------------------------------- + + // Verified + enum role { + system, + developer, + user, + assistant, + tool, + } + + // Verified + enum channel { + analysis, + commentary, + final, + } + + // Verified + enum resource-type { + file, + blob, + uri, + database, + api, + memory, + artifact, + } + + // --------------------------------------------------------------------------- + // CMF content parts + // --------------------------------------------------------------------------- + // Verified + record image-source { + source-type: string, + data: string, + media-type: option, + } + + // Verified + record video-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + // Verified + record audio-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + // Verified + record document-source { + source-type: string, + data: string, + media-type: option, + title: option, + } + + // Verified + record tool-call { + tool-call-id: string, + name: string, + // arguments is a JSON object string (HashMap) + arguments: string, + namespace: option, + } + + // Verified + record tool-result { + tool-call-id: string, + tool-name: string, + // content is a JSON string (serde_json::Value) + content: string, + is-error: bool, + } + + // Verified + 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 is a JSON object string (HashMap) + annotations: string, + version: option, + } + + // Verified + record resource-reference { + resource-request-id: string, + uri: string, + name: option, + resource-type: resource-type, + range-start: option, + range-end: option, + selector: option, + } + + // Verified + record prompt-request { + prompt-request-id: string, + name: string, + // arguments is a JSON object string (HashMap) + arguments: string, + server-id: option, + } + + // Verified + record prompt-result { + prompt-request-id: string, + prompt-name: string, + // messages is a JSON array string (Vec) — cannot be list + // because message → content-part → prompt-result is a recursive cycle, + // which WIT does not support without indirection. + messages: string, + content: option, + is-error: bool, + error-message: option, + } + + // Verified + 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), + } + + // Verified + record message { + schema-version: string, + role: role, + content: list, + channel: option, + } + + // Verified + record message-payload { + message: message, + } + + // --------------------------------------------------------------------------- + // Custom payload for arbitrary types + // --------------------------------------------------------------------------- + + record custom-payload { + payload-type: string, + payload-data: list, + } + + // --------------------------------------------------------------------------- + // Identity payload (IdentityResolve hook) + // Note: raw_token is #[serde(skip)] in Rust — never crosses the WASM + // boundary. WASM handlers resolve identity from source/headers/claims. + // --------------------------------------------------------------------------- + + // Verified + enum token-source { + bearer, + user-token, + mtls, + spiffe-jwt-svid, + api-key, + // custom source name carried as a string alongside this enum value + // via identity-payload.source-custom when this variant is selected + custom, + } + + // Verified + record identity-payload { + // Input fields (host-supplied, read-only for handlers) + source: token-source, + // non-empty when source = custom + source-custom: option, + source-header: option, + // request headers as key-value pairs — escape hatch for custom auth flows + headers: list>, + client-host: option, + client-port: option, + + // Output fields (handlers populate these on the returned payload) + subject: option, + client: option, + caller-workload: option, + // initial delegation chain parsed from act/equivalent claims + delegation: option, + // resolved_at as ISO 8601 string (DateTime) + resolved-at: option, + // raw decoded token claims as a JSON object string (HashMap) + raw-claims: option, + } + + // --------------------------------------------------------------------------- + // Delegation payload (TokenDelegate hook) + // Note: bearer_token and delegated_token.token are #[serde(skip)] in Rust — + // they never cross the WASM boundary. WASM handlers can attenuat scopes and + // populate delegation_update/metadata but cannot mint raw tokens. + // --------------------------------------------------------------------------- + + enum target-type { + tool, + agent, + %resource, + service, + // custom target kind name carried in delegation-payload.target-type-custom + custom, + } + + enum auth-enforced-by { + caller, + target, + both, + } + + enum delegation-mode { + on-behalf-of-user, + as-gateway, + } + + record attenuation-config { + capabilities: list, + resource-template: option, + actions: list, + ttl-seconds: option, + } + + // Minted outbound credential (token bytes are #[serde(skip)] — omitted). + record raw-delegated-token { + outbound-header: string, + audience: string, + scopes: list, + // expires_at as ISO 8601 string (DateTime) + expires-at: string, + } + + record delegation-payload { + // Input fields (host-supplied, read-only for handlers) + target-name: string, + target-type: target-type, + // non-empty when target-type = custom + target-type-custom: option, + target-audience: option, + required-permissions: list, + trust-domain: option, + auth-enforced-by: auth-enforced-by, + route-attenuation: option, + + // Output fields (handlers populate these on the returned payload) + // token bytes omitted — raw credential material never crosses the sandbox + delegated-token: option, + delegation-update: option, + delegation-mode: option, + // minted_at as ISO 8601 string (DateTime) + minted-at: option, + // handler metadata as a JSON object string (HashMap) + metadata: option, + } + + variant hook-payload { + cmf(message-payload), + identity(identity-payload), + delegation(delegation-payload), + custom(custom-payload), + } + + // --------------------------------------------------------------------------- + // Plugin context + // --------------------------------------------------------------------------- + + // State entries are typed key-value pairs (serde_json::Value serialized per + // entry as a JSON string) rather than a single opaque JSON blob. + record context-entry { + key: string, + // value is a JSON string representing serde_json::Value + value: string, + } + + record plugin-context { + local-state: list, + global-state: list, + } + + // --------------------------------------------------------------------------- + // Extensions — base four + // --------------------------------------------------------------------------- + + record request-extension { + environment: option, + request-id: option, + timestamp: option, + trace-id: option, + span-id: option, + } + + enum subject-type { + user, + agent, + service, + system, + } + + record subject-extension { + id: option, + subject-type: option, + roles: list, + permissions: list, + teams: list, + claims: list>, + } + + // --------------------------------------------------------------------------- + // Security extension — full coverage + // --------------------------------------------------------------------------- + + enum client-trust-level { + first-party, + third-party, + internal, + // custom trust level — name carried in client-trust-level-custom on + // client-extension when this variant is not sufficient + } + + record client-extension { + client-id: string, + client-name: option, + trust-level: client-trust-level, + // custom trust level name when trust-level cannot represent it + trust-level-custom: option, + authorized-scopes: list, + authorized-audiences: list, + roles: list, + permissions: list, + teams: list, + // claims values are JSON strings (serde_json::Value per entry) + claims: list>, + } + + record workload-identity { + spiffe-id: option, + trust-domain: option, + // attested-at as ISO 8601 string (DateTime) + attested-at: option, + attestor: option, + selectors: list, + client-id: option, + } + + record object-security-profile { + managed-by: option, + permissions: list, + trust-domain: option, + data-scope: list, + } + + record retention-policy { + max-age-seconds: option, + policy: string, + delete-after: option, + } + + record data-policy { + apply-labels: list, + // None = all actions allowed; Some([]) = no actions allowed + allowed-actions: option>, + denied-actions: list, + retention: option, + } + + record security-extension { + labels: list, + classification: option, + subject: option, + client: option, + caller-workload: option, + this-workload: option, + auth-method: option, + // object security profiles keyed by object name + objects: list>, + // data policies keyed by data element name + data: list>, + } + + record http-extension { + request-headers: list>, + response-headers: list>, + method: option, + path: option, + host: option, + scheme: option, + } + + record meta-extension { + entity-type: option, + entity-name: option, + tags: list, + scope: option, + properties: list>, + } + + // --------------------------------------------------------------------------- + // Overflow extension types — typed + // --------------------------------------------------------------------------- + + record conversation-context { + // history entries are JSON strings (serde_json::Value per entry) + history: list, + summary: option, + topics: list, + } + + record agent-extension { + input: option, + session-id: option, + conversation-id: option, + turn: option, + agent-id: option, + parent-agent-id: option, + conversation: option, + } + + record tool-metadata { + name: string, + title: option, + description: option, + // input-schema and output-schema are JSON strings (serde_json::Value) + input-schema: option, + output-schema: option, + server-id: option, + namespace: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record resource-metadata { + uri: string, + name: option, + description: option, + mime-type: option, + server-id: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record prompt-metadata { + name: string, + description: option, + // arguments is a JSON array string (Vec) + arguments: option, + server-id: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record mcp-extension { + tool: option, + // renamed from `resource` to avoid WIT keyword conflict + resource-info: option, + prompt: option, + } + + // stop-reason: `return-complete` maps to Rust `StopReason::Return` + // (renamed to avoid the WIT `return` keyword) + enum stop-reason { + end, + return-complete, + call, + max-tokens, + stop-sequence, + } + + record token-usage { + input-tokens: u32, + output-tokens: u32, + total-tokens: u32, + } + + record completion-extension { + stop-reason: option, + tokens: option, + model: option, + raw-format: option, + created-at: option, + latency-ms: option, + } + + record provenance-extension { + source: option, + message-id: option, + parent-id: option, + } + + record llm-extension { + model-id: option, + provider: option, + capabilities: list, + } + + record framework-extension { + framework: option, + framework-version: option, + node-id: option, + graph-id: option, + // metadata is a JSON object string (HashMap) + metadata: option, + } + + record authorization-detail { + detail-type: string, + // option preserves None (no constraint) vs Some([]) (empty = deny all) + locations: option>, + actions: option>, + datatypes: option>, + identifier: option, + privileges: option>, + // extra holds flattened RFC 9396 extension fields as a JSON object string + extra: option, + } + + enum delegation-strategy { + token-exchange, + client-credentials, + spiffe-svid, + passthrough, + ucan, + transaction-token, + } + + record delegation-hop { + subject-id: string, + subject-type: option, + audience: option, + scopes-granted: list, + authorization-details: list, + // timestamp as ISO 8601 string (DateTime) + timestamp: string, + ttl-seconds: option, + strategy: option, + // carries the name when Rust DelegationStrategy::Custom(s) is used + strategy-custom: option, + from-cache: bool, + } + + record delegation-extension { + chain: list, + depth: u32, + origin-subject-id: option, + actor-subject-id: option, + delegated: bool, + // age-seconds as string to avoid f64 portability issues + age-seconds: string, + } + + // --------------------------------------------------------------------------- + // Extensions container + // --------------------------------------------------------------------------- + + record extensions { + request: option, + security: option, + http: option, + meta: option, + agent: option, + mcp: option, + completion: option, + provenance: option, + llm: option, + framework: option, + delegation: option, + // custom: escape hatch for plugin-defined arbitrary key-value data + // (maps to cpex-core Extensions.custom: Option>) + custom: option, + } + + // --------------------------------------------------------------------------- + // Violation and result + // --------------------------------------------------------------------------- + + record plugin-violation { + code: string, + reason: string, + description: option, + // details is a JSON object string (HashMap) + details: string, + plugin-name: option, + proto-error-code: option, + } + + // Verified + // metadata is a JSON string (serde_json::Value) + record hook-result { + continue-processing: bool, + modified-payload: option, + modified-extensions: option, + modified-context: option, + violation: option, + metadata: option, + } +} + +interface host-logging { + enum log-level { + trace, + debug, + info, + warn, + error, + } + + log: func(level: log-level, message: string); +} + +/// Known hook names (not exhaustive - hosts may define custom hooks): +/// +/// Legacy (typed payloads): +/// "tool_pre_invoke", "tool_post_invoke" +/// "prompt_pre_fetch", "prompt_post_fetch" +/// "resource_pre_fetch", "resource_post_fetch" +/// "identity_resolve", "token_delegate" +/// +/// CMF (MessagePayload): +/// "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" +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; + import host-logging; + + use types.{hook-payload, extensions, plugin-context, hook-result}; + + export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context + ) -> hook-result; +} diff --git a/docs/content/docs/wasm/_index.md b/docs/content/docs/wasm/_index.md new file mode 100644 index 00000000..d5769289 --- /dev/null +++ b/docs/content/docs/wasm/_index.md @@ -0,0 +1,38 @@ +--- +title: "WebAssembly Plugins" +weight: 50 +bookCollapseSection: false +--- + +# WebAssembly Plugins + +Run plugins in sandboxed WebAssembly instead of trusting them with full process access. + +CPEX plugins inspect and modify requests flowing through the hook pipeline — tool calls, LLM messages, identity checks, token delegation. The `cpex-wasm-host` crate lets you execute those plugins inside a Wasmtime sandbox where each plugin gets: + +- **Isolated linear memory** — no shared address space with the host or other plugins +- **No filesystem/network/env access** unless explicitly granted via sandbox policy +- **CPU budget** — fuel-based instruction limits prevent runaway computation +- **Wall-clock timeout** — epoch-based interruption catches infinite loops and blocking calls +- **Capability-filtered extensions** — plugins only see the extension fields their config allows + +## When to use WASM plugins + +| Scenario | Recommended approach | +|----------|---------------------| +| First-party plugin, same repo | Native (in-process) — no overhead | +| Third-party or untrusted plugin | **WASM** — sandbox enforces least privilege | +| Multi-language plugin authors | **WASM** — any language targeting `wasm32-wasip2` works | +| Strict compliance / auditing | **WASM** — policy is declarative YAML, auditable | +| Performance-critical hot path | Native — avoid serialization overhead | + +## What's in this section + +- [Introduction]({{< relref "introduction" >}}) — what WebAssembly is, the Component Model, and trade-offs +- [Quickstart]({{< relref "quickstart" >}}) — build and run your first WASM plugin in minutes +- [Architecture]({{< relref "architecture" >}}) — how the host, sandbox, and guest interact +- [cpex-wasm-plugin (Guest SDK)]({{< relref "cpex-wasm-plugin" >}}) — writing plugins, WIT interface, build commands +- [cpex-wasm-host (Host Runtime)]({{< relref "cpex-wasm-host" >}}) — loading, sandboxing, and invoking plugins +- [Sandboxing Details]({{< relref "sandboxing-details" >}}) — filesystem, env, network, and resource permissions +- [Benchmarking]({{< relref "benchmarking" >}}) — performance measurements and optimization guidance +- [Tutorials]({{< relref "tutorials" >}}) — hands-on walkthroughs with the built-in demos diff --git a/docs/content/docs/wasm/architecture.md b/docs/content/docs/wasm/architecture.md new file mode 100644 index 00000000..f9ee39ae --- /dev/null +++ b/docs/content/docs/wasm/architecture.md @@ -0,0 +1,128 @@ +--- +title: "Architecture" +weight: 20 +--- + +# Architecture + +How the WASM plugin host, Wasmtime sandbox, and guest plugins interact. + +## High-level flow + +``` +┌─────────────┐ +│ Your Code │ +└──────┬──────┘ + │ invoke("hook_name", payload, extensions, ctx) + ▼ +┌─────────────────────────────────────────────────────┐ +│ PluginManager │ +│ routes hook to matching plugin(s) │ +└──────┬──────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ WasmBridgeHandler │ +│ │ +│ 1. Convert native types → WIT types │ +│ 2. Reset fuel counter + epoch deadline │ +│ 3. Call into Wasmtime sandbox │ +│ 4. Convert WIT types → native types │ +│ 5. Validate result against capability policy │ +└──────┬──────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Wasmtime Sandbox │ +│ │ +│ • Isolated linear memory (per-plugin Store) │ +│ • Fuel budget (instruction limit) │ +│ • Epoch timeout (wall-clock interrupt) │ +│ • Capability-filtered extensions │ +│ • Gated WASI: filesystem / network / env │ +└──────┬──────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Guest Plugin (.wasm) │ +│ │ +│ handle-hook(hook-name, payload, extensions, ctx) │ +│ → HookResult │ +└─────────────────────────────────────────────────────┘ +``` + +## Key components + +### SharedEngine + +A single Wasmtime `Engine` shared across all plugins loaded by one `WasmPluginFactory`. The engine holds compiled module caches and runs one background epoch-ticker thread that periodically increments the epoch counter, enabling wall-clock timeouts without per-plugin OS threads. + +### SandboxManager + +Owns a Wasmtime `Store` per plugin invocation. Responsibilities: + +- Configure fuel (instruction budget) and epoch deadline before each call +- Build the WASI context from the sandbox policy (filesystem mounts, env vars, network access) +- Trap the guest if it exceeds fuel or epoch limits +- Provide the `host-logging` import so guests can emit structured logs + +### WasmBridgeHandler + +Implements `cpex-core`'s `PluginHandler` trait. Bridges between the framework's native Rust types and the WIT component-model types that cross the WASM boundary. This layer also enforces capability filtering — if a plugin lacks a declared capability, corresponding extension fields are zeroed before the call and any modifications to them are discarded on return. + +### PayloadSerializerRegistry + +A type-erased registry that maps payload type discriminators (strings) to serialization/deserialization logic. This enables custom payload types to cross the WASM boundary without modifying the WIT interface — the `custom-payload` variant carries opaque bytes tagged with a type identifier that the registry resolves at runtime. + +## WIT interface + +The guest/host contract is defined in `wit/world.wit` under the `cpex:plugin` package. The single exported function: + +```wit +export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context, +) -> hook-result; +``` + +**`hook-payload`** is a variant (tagged union): + +| Variant | Payload type | Use case | +|---------|-------------|----------| +| `cmf` | `message-payload` | Tool calls, LLM input/output, resource fetches | +| `identity` | `identity-payload` | Identity resolution from tokens/headers | +| `delegation` | `delegation-payload` | Token delegation and attenuation | +| `custom` | `custom-payload` | User-defined payload types | + +**`hook-result`** tells the framework what to do next: + +| Variant | Effect | +|---------|--------| +| `continue-processing` | Pass through unchanged | +| `modified-payload` | Replace the payload | +| `modified-extensions` | Replace extensions | +| `modified-context` | Update plugin context state | +| `violation` | Block the request with a policy violation | +| `metadata` | Attach metadata without modifying the payload | + +## Security model (5 layers) + +1. **Memory isolation** — each plugin's linear memory is inaccessible to other plugins and the host process +2. **WASI capability gating** — filesystem, network, and environment access require explicit policy grants +3. **Fuel limits** — bounded computation prevents CPU exhaustion +4. **Epoch timeouts** — wall-clock deadlines catch blocking I/O and infinite loops +5. **Capability filtering** — extensions are masked based on declared plugin capabilities + +All layers are **deny-by-default**. A plugin with no sandbox policy config gets: no filesystem, no network, no env vars, default fuel, and default timeout. + +## Plugin lifecycle + +1. **Factory registration** — `WasmPluginFactory` is registered with `PluginManager` for the `wasm://` URI scheme +2. **Config load** — YAML config is parsed; sandbox policy is extracted per plugin +3. **Compilation** — the `.wasm` module is compiled once by the shared engine (cached) +4. **Instantiation** — each invocation creates a fresh `Store` with fuel/epoch/WASI configured +5. **Execution** — `handle-hook` is called; the guest runs within its budget +6. **Result validation** — the bridge validates the result against capability policy +7. **Teardown** — the `Store` is dropped; all guest memory is freed diff --git a/docs/content/docs/wasm/benchmarking.md b/docs/content/docs/wasm/benchmarking.md new file mode 100644 index 00000000..ec6c69fd --- /dev/null +++ b/docs/content/docs/wasm/benchmarking.md @@ -0,0 +1,143 @@ +--- +title: "Benchmarking" +weight: 60 +--- + +# Benchmarking + +Performance measurements for WASM plugin execution compared to native in-process plugins. + +## Test environment + +| Parameter | Value | +|-----------|-------| +| CPU | Apple M4 Max | +| RAM | 64 GB | +| Rust | 1.96.0 | +| Wasmtime | 45.0 | +| OS | macOS (ARM64) | + +## Results summary + +| Scenario | Latency | vs Native | +|----------|---------|-----------| +| Native no-op | 87 ns | 1× | +| Native compute | 874 ns | 10× | +| WASM no-op | 5.1 μs | 58× | +| WASM compute | 10.5 μs | 120× | +| Custom payload (JSON serde) | 5.3 μs | 61× | +| Structured payload (WIT types) | 5.6 μs | 64× | +| Cold start (compile + first call) | 547 ms | one-time | + +### Key takeaways + +- **WASM is 12-120× slower** than native depending on workload complexity +- **Cold start is ~550ms** (one-time per plugin; amortized over the process lifetime) +- **Custom vs structured payload**: nearly identical (~5μs each); the serialization format doesn't dominate +- **For typical LLM tool invoke** (2-4 plugin calls per 200ms+ LLM request): sandbox overhead is **0.01-0.02%** of total request time + +## Benchmark suites + +### Invocation overhead (`benchmarking/invocation.rs`) + +Isolates the sandbox overhead by comparing equivalent operations: + +| Benchmark | What it measures | +|-----------|-----------------| +| `native_noop` | Baseline: calling a native no-op handler directly | +| `native_with_full_extensions` | Native no-op with realistic extensions (12 types populated) | +| `conversion_native_to_wit` | Type conversion cost alone (no WASM execution) | +| `wasm_noop` | Full WASM round-trip: convert → sandbox → convert back | +| `wasm_with_full_extensions` | Full round-trip with realistic extensions | + +### Comprehensive suite (`benchmarking/comprehensive.rs`) + +End-to-end measurements including real computation: + +| Benchmark | What it measures | +|-----------|-----------------| +| `cold_start` | WASM module load + compile + first invocation | +| `real_compute_native` | Native plugin doing JSON parsing + string ops + FNV-1a hash | +| `real_compute_wasm` | Same workload inside the WASM sandbox | +| `custom_payload` | Round-trip with JSON-serialized custom payload | +| `structured_payload` | Round-trip with WIT-typed `MessagePayload` | +| `mutex_contention_N` | Throughput under concurrent access (1, 4, 8 tasks) | + +## Running benchmarks + +### Prerequisites + +```bash +cd crates/cpex-wasm-host +make build-bench-plugins # Compiles compute-bench.wasm +``` + +### Run all benchmarks + +```bash +make bench-all +``` + +This runs `cargo bench -p cpex-wasm-host` and generates a comparison chart via `plot_results.py`. + +### Run individually + +```bash +cargo bench -p cpex-wasm-host -- invocation +cargo bench -p cpex-wasm-host -- comprehensive +``` + +### Generate chart + +```bash +python3 benchmarking/plot_results.py +# Outputs: benchmarking/performance_comparison.png +``` + +## Interpreting results + +### Where the time goes (WASM no-op breakdown) + +``` +Total: ~5.1 μs +├── Fuel reset + epoch deadline: ~0.1 μs +├── Native → WIT conversion: ~1.5 μs +├── WASM function call overhead: ~1.5 μs +├── WIT → Native conversion: ~1.5 μs +└── Capability validation: ~0.5 μs +``` + +### When to use WASM vs native + +| Use WASM when | Use native when | +|---------------|-----------------| +| Plugin is third-party or untrusted | Plugin is first-party, same repo | +| Multi-language support needed | Performance is critical (sub-microsecond) | +| Audit/compliance requires sandboxing | Plugin needs shared memory with host | +| Plugin count is high (isolation per plugin) | Cold start budget is zero | + +### Practical impact + +For a typical agentic workflow: + +``` +LLM inference: 200-2000 ms +Network I/O: 50-500 ms +WASM plugin (×3): 15 μs total +───────────────────────────── +Plugin overhead: 0.003-0.06% of request +``` + +The sandbox overhead is negligible compared to LLM inference and network latency in real deployments. + +## Mutex contention + +The `SharedEngine` uses `Arc>` for thread safety. Under concurrent load: + +| Concurrent tasks | Throughput | Notes | +|-----------------|------------|-------| +| 1 | ~195k ops/sec | No contention | +| 4 | ~180k ops/sec | Minimal degradation | +| 8 | ~165k ops/sec | Lock wait becomes measurable | + +For high-concurrency deployments, consider one `SandboxManager` per thread or a pool of pre-warmed instances. diff --git a/docs/content/docs/wasm/cpex-wasm-host.md b/docs/content/docs/wasm/cpex-wasm-host.md new file mode 100644 index 00000000..d3140a3a --- /dev/null +++ b/docs/content/docs/wasm/cpex-wasm-host.md @@ -0,0 +1,234 @@ +--- +title: "cpex-wasm-host (Host Runtime)" +weight: 40 +--- + +# cpex-wasm-host — Host Runtime + +The `cpex-wasm-host` crate is the **host-side runtime** that loads, sandboxes, and invokes WASM plugins compiled from `cpex-wasm-plugin`. It integrates with `cpex-core`'s `PluginManager` so WASM plugins participate in the same hook pipeline as native plugins. + +## Purpose + +- Load `.wasm` component binaries and compile them with Wasmtime +- Enforce sandbox policies (filesystem, network, env, CPU, memory) per plugin +- Convert between the framework's native Rust types and WIT component-model types +- Validate plugin results against declared capabilities (defense-in-depth) +- Provide a `PayloadSerializerRegistry` for custom payload types crossing the WASM boundary + +## Source directory (`src/`) + +### `src/lib.rs` + +Module root. Re-exports the five public modules: + +```rust +pub mod conversions; +pub mod factory; +pub mod payload_registry; +pub mod policy_loader; +pub mod sandbox_manager; +``` + +### `src/factory.rs` + +The main integration point. Contains: + +| Component | Role | +|-----------|------| +| `WasmPluginFactory` | Implements `cpex-core`'s `PluginFactory` trait. Parses `"wasm://plugin.wasm"` URIs, extracts sandbox policy from YAML config, and creates `WasmBridgeHandler` instances per hook. | +| `WasmBridgeHandler` | Implements `AnyHookHandler`. Converts native payloads → WIT, invokes the sandbox, converts WIT → native, then validates extensions against capabilities. | +| `validate_extension_modifications` | Post-invocation check: immutable tier integrity, monotonic label enforcement, write authorization against declared capabilities. | +| `classify_wasm_error` | Maps Wasmtime error strings to typed `PluginError` variants (Timeout, FuelExhausted, MemoryLimit, NetworkDenied, WasmTrap). | + +### `src/sandbox_manager.rs` + +Manages the Wasmtime runtime for a single plugin: + +| Component | Role | +|-----------|------| +| `SharedEngine` | Shared Wasmtime `Engine` + `Linker` + epoch-ticker thread. One per factory; all plugins share compilation caches. | +| `SandboxManager` | Owns a compiled `Component` and per-invocation `Store`. Calls `load_wasmplugin()` once, then `invoke()` per hook call (resets fuel + epoch each time). | +| `NetworkPolicy` | Implements `WasiHttpHooks` to intercept outbound HTTP. Filters by host (with wildcard), port, scheme, and method. | +| `WasmPluginState` | Per-plugin store data: WASI context, HTTP context, resource table, store limits. Implements `host-logging::Host`. | + +### `src/conversions.rs` + +Host-side type conversions between `cpex-core` native types and WIT types: + +- `native_payload_to_wit` / `wit_payload_to_native` — MessagePayload +- `native_identity_to_wit` / `wit_identity_to_native` — IdentityPayload +- `native_delegation_to_wit` / `wit_delegation_to_native` — DelegationPayload +- `native_extensions_to_wit` / `wit_extensions_to_native` — all 12 extension types +- `native_context_to_wit` / `wit_context_to_native` — PluginContext + +### `src/policy_loader.rs` + +Parses sandbox policy from YAML config and builds a WASI context: + +| Type | Purpose | +|------|---------| +| `SandboxPolicy` | Top-level: `allowed_filesystem`, `allowed_network`, `allowed_env`, `resources` | +| `FilesystemRule` | Specifies a `dir` or `file` with a named permission level | +| `NetworkRule` | Host (supports `*.` wildcard), ports, schemes, methods | +| `ResourceLimits` | `max_memory_bytes`, `max_fuel`, `max_execution_time_ms`, `max_instances`, `max_tables` | +| `build_wasi_context()` | Constructs `WasiCtx` + `WasiHttpCtx` from a `SandboxPolicy`. Preopens directories, injects env vars, captures network rules. | + +### `src/payload_registry.rs` + +Type-erased serialization for custom payloads: + +```rust +let mut registry = PayloadSerializerRegistry::new(); +registry.register::(); // must impl WasmSerializablePayload + +// At runtime: +let (type_name, bytes) = registry.serialize(&payload)?; +let restored: Box = registry.deserialize(type_name, &bytes)?; +``` + +## Configuration + +YAML config files live in `config/`. Each plugin entry specifies its sandbox policy: + +```yaml +plugins: + - name: my-plugin + kind: "wasm://my-plugin.wasm" + hooks: + - cmf.tool_pre_invoke + capabilities: + - read_labels + - write_headers + config: + sandbox: + # Filesystem access + filesystem: + - dir: "./data/cache" + permission: full-access + - dir: "./data/audit" + permission: drop-box + + # Network access + network: + - host: "api.example.com" + ports: [443] + schemes: ["https"] + methods: ["GET", "POST"] + - host: "*.internal.corp" + ports: [] # any port + schemes: ["https"] + + # Environment variables + env_vars: + - APP_TOKEN + - LOG_LEVEL + + # Resource limits + resources: + max_fuel: 500_000_000 + max_execution_time_ms: 5000 + max_memory_bytes: 10_485_760 # 10 MB +``` + +### Default policy (deny-all) + +A plugin with no `sandbox` config block gets the most restrictive policy: + +- **Filesystem**: no preopened directories +- **Network**: all outbound HTTP blocked +- **Env vars**: none visible +- **Resources**: engine defaults (generous but bounded) + +### Capabilities + +The `capabilities` list controls which extension fields a plugin can read or write. The host zeros disallowed fields before the call and discards unauthorized modifications on return. + +## End-to-end example + +Here's how the **capabilities demo** runs from config to result: + +**1. Config** (`config/config_capabilities.yaml`): + +```yaml +plugins: + - name: identity-checker + kind: "wasm://identity-checker.wasm" + hooks: [cmf.tool_pre_invoke] + capabilities: [read_labels, read_subject, read_roles] + config: + sandbox: + resources: + max_fuel: 500_000_000 + max_execution_time_ms: 5000 + + - name: header-injector + kind: "wasm://header-injector.wasm" + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + capabilities: [read_headers, write_headers, append_labels] + config: + sandbox: + resources: + max_fuel: 500_000_000 + max_execution_time_ms: 5000 +``` + +**2. Host code** (from `examples/wasm_capabilities_demo.rs`): + +```rust +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_core::plugin_manager::PluginManager; + +// Create factory with built-in payload support +let factory = WasmPluginFactory::with_builtin_payloads("./wasm"); + +// Register and load config +let mut mgr = PluginManager::new(); +mgr.register_factory("wasm://identity-checker.wasm", Box::new(factory.clone())); +mgr.register_factory("wasm://header-injector.wasm", Box::new(factory)); +mgr.load_config("config/config_capabilities.yaml")?; + +// Build payload + extensions +let payload = MessagePayload { messages: vec![/* CMF message with ToolCall */] }; +let extensions = Extensions::builder() + .security(SecurityExtension { subject: "user:alice".into(), .. }) + .http(HttpExtension { headers: vec![("x-request-id", "abc123")] }) + .build(); + +// Invoke — plugins only see extensions matching their capabilities +let result = mgr.invoke_named::("cmf.tool_pre_invoke", payload, extensions, ctx).await?; +``` + +**3. What happens inside:** + +1. `WasmPluginFactory::create()` parses sandbox policy, compiles `.wasm`, creates `SandboxManager` +2. `WasmBridgeHandler` masks extensions based on plugin capabilities before conversion +3. Native types are converted to WIT types (`native_payload_to_wit`, etc.) +4. `SandboxManager::invoke()` resets fuel/epoch, calls `handle-hook` in the sandbox +5. WIT result is converted back to native types +6. `validate_extension_modifications()` checks the plugin didn't write to unauthorized fields +7. Result flows back through the `PluginManager` pipeline + +**4. Run it:** + +```bash +cd crates/cpex-wasm-host +make run-capabilities-demo +``` + +## Advantages + +- **Drop-in integration** — implements `PluginFactory` / `AnyHookHandler` from `cpex-core`; WASM plugins are transparent to the pipeline +- **Shared compilation** — `SharedEngine` caches compiled modules; multiple plugins share one engine thread +- **Fine-grained policy** — six filesystem permission levels, host/port/scheme/method network rules, explicit env var allowlists +- **Defense-in-depth** — capability validation on the return path catches sandbox escapes at the type level +- **Custom payload extensibility** — `PayloadSerializerRegistry` supports arbitrary domain types without WIT changes +- **Structured error classification** — Wasmtime errors are mapped to typed variants for meaningful error handling + +## Limitations + +- **Cold start overhead** — first compilation takes ~550ms; subsequent invocations are ~5μs +- **12-120x slower than native** — serialization + sandbox overhead; acceptable for typical LLM pipelines (0.01-0.02% of request time) +- **Single-threaded per invocation** — each `Store` is single-threaded; concurrent plugins need separate `Store` instances (handled by `Arc>`) +- **WASI P2 only** — requires Wasmtime's component model; legacy WASI P1 modules are not supported +- **Partial extension writeback** — only request, security, http, meta survive the guest→host return; other extension modifications are dropped +- **No raw socket access** — WASI P2 does not expose raw TCP/UDP; only HTTP via `wasi:http/outgoing-handler` diff --git a/docs/content/docs/wasm/cpex-wasm-plugin.md b/docs/content/docs/wasm/cpex-wasm-plugin.md new file mode 100644 index 00000000..f14baadc --- /dev/null +++ b/docs/content/docs/wasm/cpex-wasm-plugin.md @@ -0,0 +1,253 @@ +--- +title: "cpex-wasm-plugin (Guest SDK)" +weight: 30 +--- + +# cpex-wasm-plugin — Guest SDK + +The `cpex-wasm-plugin` crate is the **guest-side SDK** that plugin authors use to write CPEX plugins compiled to WebAssembly. It provides WIT bindings, type conversions, host logging, and macros that eliminate boilerplate so you can focus on hook logic. + +## What it does + +1. Generates Rust bindings from the `wit/world.wit` interface definition via `wit-bindgen` +2. Provides a prelude with all the types, traits, and macros needed to write a plugin +3. Handles bidirectional type conversion between WIT component-model types and `cpex-core` native types +4. Routes incoming hook calls to the correct handler based on payload variant (CMF, Identity, Delegation, Custom) +5. Provides structured host logging (`cpex_log!` macro) that routes messages to the host's tracing infrastructure + +## Crate structure + +### `src/lib.rs` + +The SDK glue layer. Responsibilities: + +- **WIT binding generation** — calls `wit_bindgen::generate!` for the `"plugin"` world +- **Prelude module** — re-exports traits (`HookHandler`, `Plugin`, `PluginConfig`), payload types (`CmfHook`, `MessagePayload`, `IdentityHook`, `IdentityPayload`, `TokenDelegateHook`, `DelegationPayload`), error types, and macros +- **`register_wasm_plugin!` macro** — the core macro that generates a WIT `Guest` impl. It dispatches incoming `hook-payload` variants to the appropriate `HookHandler` impl on your plugin struct +- **`host_log()` / `cpex_log!`** — structured logging from inside the sandbox, routed to the host's tracing subscriber +- **`__block_on`** — a minimal synchronous async executor (10,000-poll cap) for plugins that need to make outbound HTTP calls via WASI +- **Feature-gated demo registrations** — 17 example plugins selectable via Cargo features + +### `src/plugin.rs` + +The user-facing plugin template. This is where you write your plugin logic: + +```rust +use cpex_wasm_plugin::prelude::*; + +pub struct UserPlugin; + +impl Plugin for UserPlugin { + fn config() -> PluginConfig { + PluginConfig { + name: "my-plugin".into(), + kind: "wasm://my-plugin.wasm".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + ..Default::default() + } + } +} + +impl HookHandler for UserPlugin { + fn handle(&self, payload: &MessagePayload, ext: &Extensions, ctx: &PluginContext) + -> PluginResult + { + // Your logic here + PluginResult::allow() + } +} +``` + +### `src/conversions.rs` + +Bidirectional type conversions (~1900 lines) between WIT-generated types and `cpex-core` native types: + +| Direction | Coverage | +|-----------|----------| +| WIT → Native | MessagePayload, IdentityPayload, DelegationPayload, Extensions (all 12 types), PluginContext | +| Native → WIT | HookResult, MessagePayload, IdentityPayload, DelegationPayload, Extensions (request, security, http, meta) | + +This file also handles `CustomPayload` serialization via `WasmSerializablePayload::from_wasm_bytes` / `to_wasm_bytes`. + +### `src/examples/` + +Feature-gated example plugins compiled via Cargo features. Each demonstrates a different hook or sandbox capability: + +| Plugin | Feature flag | Purpose | +|--------|-------------|---------| +| `identity_checker` | `identity-checker` | Resolves identity from token/headers | +| `header_injector` | `header-injector` | Adds/modifies HTTP headers | +| `audit_logger` | `audit-logger` | Logs all hook invocations (fire-and-forget) | +| `token_attenuator` | `token-attenuator` | Mints scoped delegation tokens | +| `pii_guard` | `pii-guard` | Blocks PII in tool arguments | +| `remote_authz` | `remote-authz` | Makes outbound HTTP for authorization | +| `noop` | `noop` | Minimal pass-through (benchmarking baseline) | +| `fs_sandbox_demo` | `fs-sandbox-demo` | Exercises filesystem permission levels | +| `env_sandbox_demo` | `env-sandbox-demo` | Tests env variable visibility | +| `net_http_test` | `net-http-test` | Tests outbound HTTP policy enforcement | +| `compute_bench` | `compute-bench` | CPU-intensive work for perf comparison | +| `tool_invoke_checker` | `tool-invoke-checker` | Validates tool call arguments | +| `resource_test` | `resource-test` | Tests resource limit enforcement | + +## WIT interface (`wit/world.wit`) + +The WIT file defines the contract between host and guest under the `cpex:plugin` package. + +### The plugin world + +```wit +world plugin { + // WASI imports available to the guest + import wasi:io/poll; + import wasi:io/error; + import wasi:io/streams; + import wasi:clocks/monotonic-clock; + import wasi:http/types; + import wasi:http/outgoing-handler; + import host-logging; + + // The single function the guest must export + export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context, + ) -> hook-result; +} +``` + +### Key types + +**`hook-payload`** — a variant (tagged union) of all payload types: + +```wit +variant hook-payload { + cmf(message-payload), // Tool calls, LLM I/O, resource fetches + identity(identity-payload), // Identity resolution + delegation(delegation-payload), // Token delegation + custom(custom-payload), // User-defined types (opaque bytes) +} +``` + +**`hook-result`** — tells the framework what to do: + +```wit +variant hook-result { + continue-processing, // Pass through unchanged + modified-payload(...), // Replace payload + modified-extensions(...), // Replace extensions + modified-context(...), // Update context state + violation(...), // Block the request + metadata(...), // Attach metadata only +} +``` + +**`host-logging`** interface — structured logging from guest to host: + +```wit +interface host-logging { + enum log-level { trace, debug, info, warn, error } + log: func(level: log-level, message: string); +} +``` + +### WIT dependencies (`wit/deps/`) + +Standard WASI interfaces that plugins can import: `io.wit`, `clocks.wit`, `http.wit`, `filesystem.wit`, `sockets.wit`, `cli.wit`, `random.wit`. + +## Building a plugin + +### Step 1: Write plugin logic + +Edit `src/plugin.rs` (or create a new feature-gated file under `src/examples/`): + +```rust +use cpex_wasm_plugin::prelude::*; + +pub struct MyPlugin; + +impl Plugin for MyPlugin { + fn config() -> PluginConfig { + PluginConfig { + name: "my-plugin".into(), + kind: "wasm://my-plugin.wasm".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + ..Default::default() + } + } +} + +impl HookHandler for MyPlugin { + fn handle(&self, payload: &MessagePayload, ext: &Extensions, ctx: &PluginContext) + -> PluginResult + { + cpex_log!(Info, "Processing tool call"); + + // Block dangerous tools + if let Some(tool_name) = payload.messages.first() + .and_then(|m| m.content.first()) + .and_then(|c| match c { ContentPart::ToolCall(tc) => Some(&tc.name), _ => None }) + { + if tool_name == "rm_rf" { + return PluginResult::block("Dangerous tool blocked"); + } + } + + PluginResult::allow() + } +} +``` + +### Step 2: Build to WASM + +```bash +cargo build -p cpex-wasm-plugin --target wasm32-wasip2 --release +``` + +Or use the Makefile (from `crates/cpex-wasm-host/`): + +```bash +make build-all-plugins +``` + +The Makefile builds each plugin with its feature flag and places `.wasm` files in `wasm/`: + +```bash +# Single plugin build pattern: +cargo build -p cpex-wasm-plugin \ + --target wasm32-wasip2 \ + --features identity-checker \ + --release + +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm wasm/identity-checker.wasm +``` + +### Step 3: Create an example plugin (feature-gated) + +To add a new example plugin to the crate: + +1. Create `src/examples/my_new_plugin.rs` +2. Add a feature flag in `Cargo.toml`: + ```toml + [features] + my-new-plugin = [] + ``` +3. Register it in `src/examples/mod.rs` under the feature gate +4. Add the plugin name to the appropriate list in `crates/cpex-wasm-host/Makefile` + +## Advantages + +- **Single compilation target** — any language with `wasm32-wasip2` support works (Rust, Go, C/C++, AssemblyScript) +- **Type-safe boundary** — WIT component model provides a schema-enforced contract; no raw byte manipulation +- **Zero-copy prelude** — `register_wasm_plugin!` eliminates boilerplate dispatch logic +- **Structured logging** — `cpex_log!` integrates with the host's tracing infrastructure without filesystem access +- **Custom payload extensibility** — `WasmSerializablePayload` trait lets you define domain-specific payloads without modifying WIT +- **Feature-gated examples** — one crate produces many `.wasm` binaries via feature flags + +## Limitations + +- **Extension writeback is partial** — only `request`, `security`, `http`, and `meta` extensions survive the return trip; modifications to `agent`, `mcp`, `completion`, `provenance`, `llm`, `framework`, `delegation`, and `custom` extensions are silently dropped +- **Synchronous execution model** — `__block_on` polls a future up to 10,000 times; deeply async workflows may hit this cap +- **Single-function export** — all hooks route through one `handle-hook` entry point; you cannot export additional functions +- **No shared state between invocations** — each call may get a fresh `Store`; use `PluginContext` for persistence +- **WASI P2 only** — plugins must target `wasm32-wasip2`, not the older `wasm32-wasi` (P1) diff --git a/docs/content/docs/wasm/introduction.md b/docs/content/docs/wasm/introduction.md new file mode 100644 index 00000000..1757f857 --- /dev/null +++ b/docs/content/docs/wasm/introduction.md @@ -0,0 +1,150 @@ +--- +title: "Introduction" +weight: 5 +--- + +# Introduction to WebAssembly + +A primer on WebAssembly, the Component Model, and why CPEX uses them for plugin sandboxing. + +## What is WebAssembly? + +WebAssembly (WASM) is a portable binary instruction format designed as a compilation target for high-level languages. Originally created for browsers, it now runs anywhere a conformant runtime exists — servers, CLIs, embedded systems. + +- **Portable bytecode** — compile once from Rust, C, Go, or any language with a WASM backend; run on any architecture +- **Near-native speed** — ahead-of-time compiled by runtimes like Wasmtime to optimized machine code +- **Sandboxed by design** — linear memory is isolated; no access to the host filesystem, network, or environment unless explicitly granted +- **Language-agnostic** — plugin authors choose their preferred language; the host only sees the WASM binary + +## The WASM Component Model + +Core WASM operates on raw integers and floats — passing structured data between modules requires manual encoding. The Component Model is a higher-level standard that solves this interop problem by introducing typed interfaces and clear role separation. + +### Key concepts + +- **WIT (WebAssembly Interface Type)** — a human-readable IDL that defines the contract between modules. Types (records, variants, lists, options, enums) and functions are declared in `.wit` files. +- **Components** — self-contained WASM binaries that declare what they import (need from the outside) and export (provide to the outside). Unlike raw WASM modules, components carry their interface metadata. +- **Composition** — components can be linked together without sharing memory, enabling modular architectures where each component is independently sandboxed. + +### Host and guest + +The Component Model defines two roles at the trust boundary: + +- **Host** — the native process that loads and executes WASM components. It decides what capabilities to grant, instantiates the runtime, and calls exported functions on the guest. +- **Guest** — the compiled `.wasm` component running inside the sandbox. It can only use functions the host explicitly provides as imports. It cannot reach the filesystem, network, or any OS resource on its own. + +This separation is absolute — the guest has no way to escalate beyond what the host links in. + +### How the Component Model works + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ HOST PROCESS │ +│ │ +│ 1. Host compiles .wasm binary into an executable component │ +│ 2. Host links imports (functions the guest may call) │ +│ 3. Host calls an exported function on the guest │ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ WASM Runtime (Wasmtime) │ │ +│ │ │ │ +│ │ ┌──────────────────┐ WIT Interface ┌─────────────────┐ │ │ +│ │ │ Host Functions │◄──── imports ───│ Guest Component │ │ │ +│ │ │ (imports) │ │ (.wasm binary) │ │ │ +│ │ │ │──── exports ───►│ │ │ │ +│ │ │ • Logging │ │ • Exported fn() │ │ │ +│ │ │ • Filesystem │ │ │ │ │ +│ │ │ • HTTP client │ │ Own memory: │ │ │ +│ │ │ • Clocks │ │ [isolated heap] │ │ │ +│ │ └──────────────────┘ └─────────────────┘ │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ 4. Guest executes, calling imports as needed │ +│ 5. Guest returns result; host reads and validates it │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**The communication cycle:** + +1. **Compile** — the host loads the `.wasm` binary and compiles it to native code via the runtime +2. **Link** — the host selectively provides import functions (filesystem, HTTP, logging, etc.) based on policy. Functions not linked simply do not exist from the guest's perspective. +3. **Call** — the host invokes an exported function on the guest, passing data as WIT-typed values (records, variants, lists). The Component Model handles serialization/deserialization at the boundary automatically. +4. **Execute** — the guest runs in its own linear memory. It can call imported functions (e.g., log a message, make an HTTP request) but nothing else. +5. **Return** — the guest returns a typed result. The host validates it and applies any post-processing. + +All data crosses the boundary **by value** — there are no shared pointers or references between host and guest memory. A crash or trap in the guest is contained; the host process is unaffected. + +## How CPEX uses this + +CPEX leverages the Component Model to run untrusted or third-party plugins in a controlled sandbox. At a high level: + +``` +┌─────────────┐ ┌───────────────────┐ ┌──────────────────┐ +│ Your App │────────►│ cpex-wasm-host │────────►│ Plugin (.wasm) │ +│ │ invoke │ │ handle │ │ +│ payload + │ │ • Sandbox policy │ -hook │ • Inspects data │ +│ extensions │ │ • Fuel/timeout │ │ • Returns allow │ +│ │◄────────│ • Capability │◄────────│ or block │ +│ │ result │ filtering │ result │ │ +└─────────────┘ └───────────────────┘ └──────────────────┘ +``` + +- Plugins target `wasm32-wasip2` and are built with the `cpex-wasm-plugin` guest SDK +- The host loads plugins and defines their sandbox policy (filesystem paths, network hosts, env vars, resource budgets) in declarative YAML +- Each plugin exports a single function — `handle-hook` — which the host calls during the pipeline +- The WIT contract (`wit/world.wit`) defines the exact types flowing across the boundary: + +```wit +world plugin { + import wasi:io/poll + import wasi:io/streams + import wasi:clocks/monotonic-clock + import wasi:http/outgoing-handler + import host-logging + + export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context + ) -> hook-result +} +``` + +The host controls every dimension of the guest's execution: + +| Dimension | Mechanism | Effect | +|-----------|-----------|--------| +| CPU | Fuel budget | Traps after N instructions | +| Wall-clock | Epoch deadline | Interrupts after N ms | +| Memory | Store limiter | Denies `memory.grow` beyond cap | +| Filesystem | Preopened dirs | Only listed paths visible | +| Network | Host allowlist | Only listed hosts reachable | +| Env vars | Explicit inject | Only listed vars exist | +| Capabilities | Extension filter | Only permitted fields visible | + +## Advantages + +- **Security isolation** — each plugin runs in its own linear memory with no access to host state unless the sandbox policy explicitly grants it +- **Deterministic resource limits** — fuel budgets cap CPU, epoch deadlines enforce wall-clock timeouts, and memory limits prevent unbounded allocation +- **Language flexibility** — plugin authors are not locked to Rust; any language compiling to `wasm32-wasip2` works (Rust, C/C++, Go, JavaScript via weval) +- **Auditability** — sandbox policy is declarative YAML; what a plugin can access is visible in configuration, not buried in code +- **Safe hot-reload** — replacing a `.wasm` file and re-instantiating carries no risk of memory corruption or dangling state from the previous version +- **No shared-memory bugs** — plugins cannot corrupt host memory or each other; a trapped plugin is cleanly dropped without affecting the process +- **Least privilege by default** — a plugin with no policy has zero capabilities; access must be explicitly opted in + +## Limitations + +- **Serialization overhead** — data crossing the WASM boundary must be serialized/deserialized (the Component Model automates this but the cost is non-zero; typically ~50-200μs per invocation) +- **Cold-start compilation** — the first instantiation of a `.wasm` binary incurs JIT/AOT compilation time (mitigated by caching compiled modules) +- **Ecosystem maturity** — WASI Preview 2 is stable but some host APIs (e.g., async I/O, sockets) are still evolving +- **No direct host memory access** — plugins cannot share references or zero-copy buffers with the host; all data is copied across the boundary +- **Debugging** — stack traces from trapped plugins show WASM offsets rather than source lines; `WASMTIME_BACKTRACE_DETAILS=1` helps but is not as ergonomic as native debugging +- **Binary size** — WASM binaries include their own allocator and standard library; typical plugin size is 2-5 MB (does not affect runtime performance) + +## Next steps + +- [Quickstart]({{< relref "quickstart" >}}) — build and run your first WASM plugin in under five minutes +- [Architecture]({{< relref "architecture" >}}) — detailed look at the host internals, sandbox manager, and bridge handler diff --git a/docs/content/docs/wasm/quickstart.md b/docs/content/docs/wasm/quickstart.md new file mode 100644 index 00000000..86f46871 --- /dev/null +++ b/docs/content/docs/wasm/quickstart.md @@ -0,0 +1,122 @@ +--- +title: "Quickstart" +weight: 10 +--- + +# WASM Plugin Quickstart + +Get a sandboxed WASM plugin running end-to-end in under five minutes. + +## Prerequisites + +Add the WASI P2 compilation target: + +```bash +rustup target add wasm32-wasip2 +``` + +## 1. Write the guest plugin + +Create a new crate for your plugin: + +```bash +cargo new --lib my-plugin +cd my-plugin +``` + +Add the guest SDK dependency in `Cargo.toml`: + +```toml +[lib] +crate-type = ["cdylib"] + +[dependencies] +cpex-wasm-plugin = { path = "../crates/cpex-wasm-plugin" } +``` + +Implement the hook handler in `src/lib.rs`: + +```rust +use cpex_wasm_plugin::prelude::*; + +struct MyPlugin; + +impl Guest for MyPlugin { + fn handle_hook( + hook_name: String, + payload: HookPayload, + extensions: Extensions, + ctx: PluginContext, + ) -> HookResult { + host_log(LogLevel::Info, &format!("Hook invoked: {hook_name}")); + + // Pass through unmodified + HookResult::ContinueProcessing + } +} + +export_plugin!(MyPlugin); +``` + +## 2. Build to WASM + +```bash +cargo build --target wasm32-wasip2 --release +``` + +The compiled module lands at `target/wasm32-wasip2/release/my_plugin.wasm`. + +## 3. Configure the host + +Create a YAML config that loads your plugin: + +```yaml +plugins: + - name: my-plugin + kind: "wasm://my_plugin.wasm" + hooks: + - tool_pre_invoke + capabilities: [] + config: + sandbox: + filesystem: deny-all + network: deny-all + env_vars: deny-all + fuel_limit: 500_000_000 + epoch_timeout_ms: 5000 +``` + +## 4. Run from the host + +```rust +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_core::plugin_manager::PluginManager; + +let factory = WasmPluginFactory::with_builtin_payloads("./wasm"); +let mut mgr = PluginManager::new(); +mgr.register_factory("wasm://my_plugin.wasm", Box::new(factory)); +mgr.load_config("config.yaml")?; + +// Invoke the hook pipeline as normal +let result = mgr.invoke("tool_pre_invoke", payload, extensions, context).await?; +``` + +## 5. Run the built-in demos + +The crate ships with ready-to-run demos that build plugins and exercise the full pipeline: + +```bash +cd crates/cpex-wasm-host +make run-demos +``` + +This compiles all example guest plugins and runs: + +- **Plugin demo** — 4 plugins across 7 hook scenarios +- **Capabilities demo** — capability-based extension filtering +- **Filesystem sandbox demo** — all 6 permission levels +- **Env sandbox demo** — allow/deny environment variable access + +## Next steps + +- [Architecture]({{< relref "architecture" >}}) — understand the host/guest boundary and security layers diff --git a/docs/content/docs/wasm/recordings/capabilities-demo.cast b/docs/content/docs/wasm/recordings/capabilities-demo.cast new file mode 100644 index 00000000..a3cdaa7d --- /dev/null +++ b/docs/content/docs/wasm/recordings/capabilities-demo.cast @@ -0,0 +1,23 @@ +{"version":3,"term":{"cols":146,"rows":41,"type":"xterm-256color","theme":{"fg":"#28fe14","bg":"#000000","palette":"#000000:#990000:#00a600:#999900:#0000b3:#b300b3:#00a6b3:#bfbfbf:#666666:#e60000:#00d900:#e6e600:#0000ff:#e600e6:#00e6e6:#e6e6e6"}},"timestamp":1786115619,"command":"cargo run -p cpex-wasm-host --example wasm_capabilities_demo","env":{"SHELL":"/bin/zsh"}} +[0.317, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m cpex-wasm-host v0.2.2 (/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/crates/cpex-wasm-host)\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 403/404: wasm_capabilities_demo(example) \r"] +[1.689, "o", "\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 1.92s\r\n"] +[0.009, "o", "\u001b[1m\u001b[92m Running\u001b[0m `target/debug/examples/wasm_capabilities_demo`\r\n"] +[0.881, "o", "=== WASM Capabilities Demo ===\r\n\r\n"] +[0.000, "o", "Loading config: /Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/crates/cpex-wasm-host/config/config_capabilities.yaml\r\n"] +[3.655, "o", "\u001b[2m2026-08-07T15:13:45.749313Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Registered plugin 'identity-checker' (kind: 'wasm://identity-checker.wasm') for hooks: [\"cmf.tool_pre_invoke\", \"cmf.tool_post_invoke\"]\r\n"] +[3.354, "o", "\u001b[2m2026-08-07T15:13:49.103346Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Registered plugin 'header-injector' (kind: 'wasm://header-injector.wasm') for hooks: [\"cmf.tool_pre_invoke\"]\r\n"] +[3.351, "o", "\u001b[2m2026-08-07T15:13:52.454338Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Registered plugin 'audit-logger' (kind: 'wasm://audit-logger.wasm') for hooks: [\"cmf.tool_pre_invoke\", \"cmf.tool_post_invoke\"]\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T15:13:52.454402Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Initializing PluginManager with 3 plugins\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T15:13:52.454448Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m PluginManager initialized successfully\r\n"] +[2.001, "o", "\u001b[1;36m=== Phase 1: cmf.tool_pre_invoke ===\r\n\u001b[0m\r\n"] +[0.006, "o", "\u001b[2m2026-08-07T15:13:54.461531Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m injected header 'X-Processed-By' and label 'PROCESSED' \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mheader-injector\r\n"] +[0.002, "o", "\u001b[2m2026-08-07T15:13:54.463454Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m AUDIT[PRE]: tool='get_compensation' labels=[\"PROCESSED\"] request_id='req-abc-123' \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0maudit-logger\r\n"] +[0.000, "o", "\r\n\u001b[97mPre-invoke result: ALLOWED\u001b[0m\r\n Labels after pre-invoke: [\"PROCESSED\"]\r\n Headers after pre-invoke: {\"Authorization\": \"Bearer eyJ...\", \"X-Processed-By\": \"header-injector\", \"X-Request-ID\": \"req-abc-123\"}\r\n"] +[2.002, "o", "\r\n--- Tool 'get_compensation' executes... ---\r\n Result: {\"salary\": 150000, \"currency\": \"USD\"}\r\n\r\n"] +[2.003, "o", "\u001b[1;36m=== Phase 2: cmf.tool_post_invoke ===\r\n\u001b[0m\r\n"] +[0.002, "o", "\u001b[2m2026-08-07T15:13:58.470543Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m AUDIT[POST]: tool='get_compensation' labels=[\"PROCESSED\"] request_id='req-abc-123' error=false \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0maudit-logger\r\n"] +[0.000, "o", "\r\n\u001b[97mPost-invoke result: ALLOWED\u001b[0m\r\n"] +[2.003, "o", "\r\n=== Demo complete ===\r\n"] +[0.043, "x", "0"] diff --git a/docs/content/docs/wasm/recordings/env-sandbox-demo.cast b/docs/content/docs/wasm/recordings/env-sandbox-demo.cast new file mode 100644 index 00000000..b835d47a --- /dev/null +++ b/docs/content/docs/wasm/recordings/env-sandbox-demo.cast @@ -0,0 +1,20 @@ +{"version":3,"term":{"cols":146,"rows":41,"type":"xterm-256color","theme":{"fg":"#28fe14","bg":"#000000","palette":"#000000:#990000:#00a600:#999900:#0000b3:#b300b3:#00a6b3:#bfbfbf:#666666:#e60000:#00d900:#e6e600:#0000ff:#e600e6:#00e6e6:#e6e6e6"}},"timestamp":1786119863,"command":"cd crates/cpex-wasm-host && cargo run --example wasm_env_sandbox_demo","env":{"SHELL":"/bin/zsh"}} +[0.216, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m cpex-wasm-host v0.2.2 (/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/crates/cpex-wasm-host)\r\n\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 403/404: wasm_env_sandbox_demo(example) "] +[0.000, "o", "\r"] +[0.798, "o", "\u001b[K\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 0.94s\r\n"] +[0.007, "o", "\u001b[1m\u001b[92m Running\u001b[0m `/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/target/debug/examples/wasm_env_sandbox_demo`\r\n"] +[0.847, "o", "\u001b[1m=== Environment Variable Sandbox Permissions Demo ===\u001b[0m\r\n\r\n\u001b[2mPlugin:\u001b[0m env-sandbox-demo.wasm\r\n"] +[0.000, "o", "\u001b[2mPayload:\u001b[0m env_var passed as ToolCall argument\r\n\r\n\u001b[2mHost env vars set for this demo:\u001b[0m\r\n CPEX_APP_TOKEN = tok-demo-abc123 (in allowed_env → visible)\r\n"] +[0.000, "o", " CPEX_LOG_LEVEL = info (in allowed_env → visible)\r\n"] +[0.000, "o", " HOME = /Users/shritipriya (not in allowed_env → hidden)\r\n PATH = (not in allowed_env → hidden)\r\n SECRET_API_KEY = sk-super-secret-* (not in allowed_env → hidden)\r\n\r\n"] +[3.575, "o", "\u001b[1;36mScenario 1: allowed_env variables (visible inside sandbox)\u001b[0m\r\n"] +[0.001, "o", " \u001b[2m[ALLOW expected]\u001b[0m env_var=CPEX_APP_TOKEN\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m env_var=CPEX_LOG_LEVEL\r\n \u001b[32m→ ALLOW\u001b[0m\r\n\r\n"] +[0.000, "o", "\u001b[1;36mScenario 2: variables NOT in allowed_env (hidden inside sandbox)\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T16:24:29.171515Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [env-sandbox-demo] DENY: 'HOME' not visible — environment variable not found \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0menv-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m env_var=HOME\r\n \u001b[31m→ DENY [env_access_denied]\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T16:24:29.171684Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [env-sandbox-demo] DENY: 'PATH' not visible — environment variable not found \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0menv-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m env_var=PATH\r\n \u001b[31m→ DENY [env_access_denied]\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T16:24:29.171804Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [env-sandbox-demo] DENY: 'SECRET_API_KEY' not visible — environment variable not found \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0menv-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m env_var=SECRET_API_KEY\r\n \u001b[31m→ DENY [env_access_denied]\u001b[0m\r\n\r\n\u001b[1m=== Demo complete ===\u001b[0m\r\n\r\n"] +[0.006, "x", "0"] diff --git a/docs/content/docs/wasm/recordings/fs-sandbox-demo.cast b/docs/content/docs/wasm/recordings/fs-sandbox-demo.cast new file mode 100644 index 00000000..04c983f6 --- /dev/null +++ b/docs/content/docs/wasm/recordings/fs-sandbox-demo.cast @@ -0,0 +1,30 @@ +{"version":3,"term":{"cols":146,"rows":41,"type":"xterm-256color","theme":{"fg":"#28fe14","bg":"#000000","palette":"#000000:#990000:#00a600:#999900:#0000b3:#b300b3:#00a6b3:#bfbfbf:#666666:#e60000:#00d900:#e6e600:#0000ff:#e600e6:#00e6e6:#e6e6e6"}},"timestamp":1786119638,"command":"cd crates/cpex-wasm-host && cargo run --example wasm_fs_sandbox_demo","env":{"SHELL":"/bin/zsh"}} +[0.205, "o", "\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 0.14s\r\n"] +[0.007, "o", "\u001b[1m\u001b[92m Running\u001b[0m `/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/target/debug/examples/wasm_fs_sandbox_demo`\r\n"] +[0.888, "o", "\u001b[1m=== Filesystem Sandbox Permissions Demo ===\u001b[0m\r\n\r\n\u001b[2mPlugin:\u001b[0m fs-sandbox-demo.wasm\r\n"] +[0.000, "o", "\u001b[2mPayload:\u001b[0m operation + path passed as ToolCall arguments\r\n\r\n"] +[3.555, "o", "\u001b[1;36mScenario 1: read-only (rules/) DirPerms::READ FilePerms::READ\u001b[0m\r\n"] +[0.003, "o", " \u001b[2m[ALLOW expected]\u001b[0m operation=read path=examples/data/rules/policy.yaml\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T16:20:42.784578Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [fs-sandbox-demo] DENY: write on 'examples/data/rules/policy.yaml' — Operation not permitted (os error 63) \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mfs-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m operation=write path=examples/data/rules/policy.yaml\r\n \u001b[31m→ DENY [fs_access_denied]\u001b[0m\r\n\r\n\u001b[1;36mScenario 2: full-access (cache/) DirPerms::READ|MUTATE FilePerms::READ|WRITE\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m operation=write path=examples/data/cache/output.txt\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.001, "o", " \u001b[2m[ALLOW expected]\u001b[0m operation=read path=examples/data/cache/output.txt\r\n \u001b[32m→ ALLOW\u001b[0m\r\n\r\n\u001b[1;36mScenario 3: drop-box (audit/) DirPerms::MUTATE FilePerms::WRITE\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T16:20:42.785732Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [fs-sandbox-demo] DENY: create_dir on 'examples/data/audit/events' — File exists (os error 20) \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mfs-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m operation=create_dir path=examples/data/audit/events\r\n \u001b[31m→ DENY [fs_access_denied]\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T16:20:42.785870Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [fs-sandbox-demo] DENY: read on 'examples/data/audit/events' — Operation not permitted (os error 63) \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mfs-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m operation=read path=examples/data/audit/events\r\n \u001b[31m→ DENY [fs_access_denied]\u001b[0m\r\n\r\n"] +[0.000, "o", "\u001b[1;36mScenario 4: fixed-mutable (counters/) DirPerms::READ FilePerms::READ|WRITE\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m operation=read path=examples/data/counters/rate.txt\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T16:20:42.786281Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [fs-sandbox-demo] DENY: create_dir on 'examples/data/counters/new' — Operation not permitted (os error 63) \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mfs-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m operation=create_dir path=examples/data/counters/new\r\n \u001b[31m→ DENY [fs_access_denied]\u001b[0m\r\n\r\n\u001b[1;36mScenario 5: list-only (plugins/) DirPerms::READ FilePerms::empty()\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m operation=list_dir path=examples/data/plugins\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T16:20:42.786894Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [fs-sandbox-demo] DENY: read on 'examples/data/plugins/fs-sandbox-demo.wasm' — Bad file descriptor (os error 8) \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mfs-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m operation=read path=examples/data/plugins/fs-sandbox-demo.wasm\r\n \u001b[31m→ DENY [fs_access_denied]\u001b[0m\r\n"] +[0.000, "o", "\r\n\u001b[1;36mScenario 6: private-scratch (scratch/) DirPerms::MUTATE FilePerms::READ|WRITE\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T16:20:42.787073Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [fs-sandbox-demo] DENY: create_dir on 'examples/data/scratch/work' — File exists (os error 20) \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mfs-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m operation=create_dir path=examples/data/scratch/work\r\n \u001b[31m→ DENY [fs_access_denied]\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T16:20:42.787186Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [fs-sandbox-demo] DENY: list_dir on 'examples/data/scratch' — Operation not permitted (os error 63) \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mfs-sandbox-demo\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m operation=list_dir path=examples/data/scratch\r\n \u001b[31m→ DENY [fs_access_denied]\u001b[0m\r\n"] +[0.000, "o", "\r\n"] +[0.000, "o", "\u001b[1m=== Demo complete ===\u001b[0m\r\n\r\n"] +[0.007, "x", "0"] diff --git a/docs/content/docs/wasm/recordings/network-sandbox-demo.cast b/docs/content/docs/wasm/recordings/network-sandbox-demo.cast new file mode 100644 index 00000000..6433f79f --- /dev/null +++ b/docs/content/docs/wasm/recordings/network-sandbox-demo.cast @@ -0,0 +1,39 @@ +{"version":3,"term":{"cols":146,"rows":41,"type":"xterm-256color","theme":{"fg":"#28fe14","bg":"#000000","palette":"#000000:#990000:#00a600:#999900:#0000b3:#b300b3:#00a6b3:#bfbfbf:#666666:#e60000:#00d900:#e6e600:#0000ff:#e600e6:#00e6e6:#e6e6e6"}},"timestamp":1786119996,"command":"cd crates/cpex-wasm-host && cargo run --example wasm_network_policy_demo","env":{"SHELL":"/bin/zsh"}} +[0.209, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m cpex-wasm-host v0.2.2 (/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/crates/cpex-wasm-host)\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 403/404: wasm_network_policy_demo(example) \r"] +[0.750, "o", "\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 0.89s\r\n"] +[0.008, "o", "\u001b[1m\u001b[92m Running\u001b[0m `/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/target/debug/examples/wasm_network_policy_demo`\r\n"] +[0.883, "o", "\u001b[1m=== Network Policy Sandbox Demo ===\u001b[0m\r\n\r\n\u001b[2mPlugin:\u001b[0m net-http-test.wasm\r\n\u001b[2mPayload:\u001b[0m url + method passed as ToolCall arguments\r\n\u001b[2mResult:\u001b[0m plugin writes 'allowed' or 'denied' into local_state\r\n\r\n"] +[0.008, "o", "\u001b[1;36m--- Scenario 1: no policy (deny-by-default)\u001b[0m\r\n"] +[3.352, "o", "\u001b[2m2026-08-07T16:26:41.245258Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m no explicit max_execution_time_ms configured — using default 5000ms \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mnet-deny-all\r\n"] +[0.002, "o", " \u001b[2m[DENY expected]\u001b[0m GET https://example.com/\r\n \u001b[31m→ DENY \u001b[0m\r\n\r\n\u001b[1;36m--- Scenario 2: host allowlist allowed=[example.com]\u001b[0m\r\n"] +[3.350, "o", "\u001b[2m2026-08-07T16:26:44.597625Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m no explicit max_execution_time_ms configured — using default 5000ms \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mnet-allowlist\r\n"] +[0.001, "o", " \u001b[2m[ALLOW expected]\u001b[0m GET https://example.com/\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m GET https://other.com/\r\n \u001b[31m→ DENY \u001b[0m\r\n\r\n"] +[0.000, "o", "\u001b[1;36m--- Scenario 3: wildcard host allowed=[*.example.com]\u001b[0m\r\n"] +[3.489, "o", "\u001b[2m2026-08-07T16:26:48.087776Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m no explicit max_execution_time_ms configured — using default 5000ms \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mnet-wildcard\r\n"] +[0.001, "o", " \u001b[2m[ALLOW expected]\u001b[0m GET https://api.example.com/\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m GET https://data.example.com/\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m GET https://example.com/\r\n \u001b[31m→ DENY \u001b[0m\r\n"] +[0.000, "o", "\r\n"] +[0.000, "o", "\u001b[1;36m--- Scenario 4: port enforcement allowed_ports=[443]\u001b[0m\r\n"] +[3.429, "o", "\u001b[2m2026-08-07T16:26:51.517502Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m no explicit max_execution_time_ms configured — using default 5000ms \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mnet-port\r\n"] +[0.001, "o", " \u001b[2m[ALLOW expected]\u001b[0m GET https://example.com/ (port 443, implicit)\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m GET https://example.com:8080/ (port 8080)\r\n \u001b[31m→ DENY \u001b[0m\r\n"] +[0.000, "o", "\r\n"] +[0.000, "o", "\u001b[1;36m--- Scenario 5: scheme enforcement allowed_schemes=[https]\u001b[0m\r\n"] +[3.377, "o", "\u001b[2m2026-08-07T16:26:54.895650Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m no explicit max_execution_time_ms configured — using default 5000ms \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mnet-scheme\r\n"] +[0.001, "o", " \u001b[2m[ALLOW expected]\u001b[0m GET https://example.com/ (https)\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m GET http://example.com/ (http)\r\n \u001b[31m→ DENY \u001b[0m\r\n\r\n"] +[0.000, "o", "\u001b[1;36m--- Scenario 6: method enforcement allowed_methods=[GET]\u001b[0m\r\n"] +[3.406, "o", "\u001b[2m2026-08-07T16:26:58.302128Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m no explicit max_execution_time_ms configured — using default 5000ms \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mnet-method\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m GET https://example.com/\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.001, "o", " \u001b[2m[DENY expected]\u001b[0m POST https://example.com/\r\n \u001b[31m→ DENY \u001b[0m\r\n\r\n\u001b[1;36m--- Scenario 7: multi-rule [api.example.com GET:443] [data.example.com GET+POST:443,8443]\u001b[0m\r\n"] +[3.372, "o", "\u001b[2m2026-08-07T16:27:01.675487Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m no explicit max_execution_time_ms configured — using default 5000ms \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mnet-multi\r\n"] +[0.001, "o", " \u001b[2m[ALLOW expected]\u001b[0m GET https://api.example.com/\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m POST https://api.example.com/\r\n \u001b[31m→ DENY \u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m POST https://data.example.com/\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[ALLOW expected]\u001b[0m GET https://data.example.com:8443/\r\n \u001b[32m→ ALLOW\u001b[0m\r\n"] +[0.000, "o", " \u001b[2m[DENY expected]\u001b[0m GET https://other.com/\r\n \u001b[31m→ DENY \u001b[0m\r\n\r\n\u001b[1m=== Demo complete ===\u001b[0m\r\n\r\n"] +[0.025, "x", "0"] diff --git a/docs/content/docs/wasm/recordings/plugin-demo.cast b/docs/content/docs/wasm/recordings/plugin-demo.cast new file mode 100644 index 00000000..e8d00c70 --- /dev/null +++ b/docs/content/docs/wasm/recordings/plugin-demo.cast @@ -0,0 +1,640 @@ +{"version":3,"term":{"cols":132,"rows":43,"type":"xterm-256color","theme":{"fg":"#28fe14","bg":"#000000","palette":"#000000:#990000:#00a600:#999900:#0000b3:#b300b3:#00a6b3:#bfbfbf:#666666:#e60000:#00d900:#e6e600:#0000ff:#e600e6:#00e6e6:#e6e6e6"}},"timestamp":1786067769,"command":"cargo run -p cpex-wasm-host --example wasm_plugin_demo","env":{"SHELL":"/bin/zsh"}} +[0.274, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m proc-macro2 v1.0.107\r\n\u001b[1m\u001b[92m Compiling\u001b[0m unicode-ident v1.0.24\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m quote v1.0.47\r\n\u001b[1m\u001b[92m Compiling\u001b[0m libc v0.2.188\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m serde_core v1.0.229\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m foldhash v0.2.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m serde v1.0.229\r\n\u001b[1m\u001b[92m Compiling\u001b[0m cfg-if v1.0.4\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m equivalent v1.0.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m allocator-api2 v0.2.21\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m libm v0.2.16\r\n\u001b[1m\u001b[92m Compiling\u001b[0m anyhow v1.0.104\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m shlex v2.0.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m find-msvc-tools v0.1.9\r\n\u001b[1m\u001b[96m Building\u001b[0m [ ] 0/404: allocator-api2, serde_core(build.rs), serde(build.rs), proc-macro2(build.rs), l…\r"] +[0.047, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m memchr v2.8.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [ ] 1/404: allocator-api2, serde_core(build.rs), serde(build.rs), proc-macro2(build.rs), l…\r"] +[0.009, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m bitflags v2.13.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [ ] 2/404: allocator-api2, serde_core(build.rs), bitflags, serde(build.rs), proc-macro2(bu…\r"] +[0.004, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m hashbrown v0.17.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [ ] 3/404: allocator-api2, serde_core(build.rs), bitflags, serde(build.rs), proc-macro2(bu…\r"] +[0.040, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-core v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[ ] 4/404: allocator-api2, serde_core(build.rs), bitflags, serde(build.rs), proc-macro2(bu…\r"] +[0.014, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m log v0.4.33\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [ ] 5/404: allocator-api2, serde_core(build.rs), bitflags, serde(build.rs), proc-macro2(bu…\r"] +[0.022, "o", "\u001b[1m\u001b[96m Building\u001b[0m [ ] 6/404: allocator-api2, serde_core(build.rs), bitflags, serde(build.rs), proc-macro2(bu…\r"] +[0.024, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m version_check v0.9.5\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[ ] 7/404: allocator-api2, serde_core(build.rs), serde(build.rs), proc-macro2(build.rs), l…"] +[0.000, "o", "\r"] +[0.016, "o", "\u001b[1m\u001b[96m Building\u001b[0m [ ] 8/404: allocator-api2, serde_core(build.rs), serde(build.rs), proc-macro2(build.rs), l…\r"] +[0.014, "o", "\u001b[1m\u001b[96m Building\u001b[0m [ ] 9/404: allocator-api2, serde_core(build.rs), hashbrown, serde(build.rs), proc-macro2(b…\r"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [ ] 10/404: allocator-api2, serde_core(build.rs), serde_core(build), hashbrown, serde(build…\r"] +[0.007, "o", "\u001b[1m\u001b[96m Building\u001b[0m [ ] 11/404: serde_core(build.rs), serde_core(build), wasmtime-internal-core(build.rs), hash…\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [ ] 12/404: serde_core(build.rs), serde_core(build), wasmtime-internal-core(build.rs), hash…\r"] +[0.017, "o", "\u001b[1m\u001b[96m Building\u001b[0m [ ] 13/404: serde_core(build.rs), serde_core(build), wasmtime-internal-core(build.rs), hash…\r"] +[0.015, "o", "\u001b[1m\u001b[96m Building\u001b[0m [ ] 14/404: serde_core(build.rs), serde_core(build), wasmtime-internal-core(build.rs), hash…\r"] +[0.005, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m itoa v1.0.18\r\n\u001b[1m\u001b[96m Building\u001b[0m [ ] 15/404: serde_core(build.rs), serde_core(build), wasmtime-internal-core(build.rs), hash…\r"] +[0.012, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 16/404: serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-macro2(bui…\r"] +[0.015, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m pin-project-lite v0.2.17\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 17/404: serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-macro2(bui…\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 18/404: serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-macro2(bui…\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 19/404: serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-macro2(bui…\r"] +[0.009, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 20/404: serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-macro2(bui…"] +[0.000, "o", "\r"] +[0.003, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 21/404: serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-macro2(bui…\r"] +[0.008, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m typenum v1.20.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 22/404: typenum, serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-m…\r"] +[0.008, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m heck v0.5.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 23/404: typenum, serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-m…\r"] +[0.006, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m target-lexicon v0.13.5\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 24/404: typenum, serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-m…\r"] +[0.001, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m fnv v1.0.7\r\n\u001b[1m\u001b[96m Building\u001b[0m [> ] 25/404: typenum, serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-m…\r"] +[0.005, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m generic-array v0.14.9\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 26/404: typenum, serde_core(build), wasmtime-internal-core(build.rs), hashbrown, proc-m…\r"] +[0.015, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 27/404: typenum, serde_core(build), hashbrown, proc-macro2(build), fnv, libc(build), he…\r"] +[0.012, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-srcgen v0.132.3\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 28/404: typenum, serde_core(build), cranelift-srcgen, hashbrown, proc-macro2(build), li…\r"] +[0.053, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 29/404: typenum, cranelift-srcgen, hashbrown, proc-macro2(build), libc(build), heck, li…\r"] +[0.009, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 30/404: typenum, generic-array(build), cranelift-srcgen, hashbrown, proc-macro2(build),…\r"] +[0.003, "o", "\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-assembler-x64-meta v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [> ] 31/404: typenum, generic-array(build), cranelift-srcgen, hashbrown, proc-macro2(build),…\r"] +[0.027, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zmij v1.0.23\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 32/404: typenum, generic-array(build), cranelift-srcgen, proc-macro2(build), zmij(build…\r"] +[0.019, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rustix v1.1.4\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 33/404: typenum, generic-array(build), proc-macro2(build), zmij(build.rs), libc(build),…\r"] +[0.036, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m futures-core v0.3.33\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 34/404: typenum, generic-array(build), proc-macro2(build), zmij(build.rs), libc(build),…\r"] +[0.016, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 35/404: typenum, generic-array(build), proc-macro2(build), zmij(build.rs), libc(build),…\r"] +[0.034, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m once_cell v1.21.4\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 36/404: typenum, generic-array(build), proc-macro2(build), zmij(build.rs), libc(build),…\r"] +[0.012, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 37/404: typenum, generic-array(build), proc-macro2(build), libc(build), libm(build), on…\r"] +[0.005, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m leb128fmt v0.1.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 38/404: generic-array(build), proc-macro2(build), libc(build), libm(build), once_cell, …\r"] +[0.046, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 39/404: generic-array(build), proc-macro2(build), libc(build), libm(build), once_cell, …\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m hashbrown v0.16.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 40/404: generic-array(build), proc-macro2(build), libc(build), libm(build), hashbrown, …\r"] +[0.004, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m serde_json v1.0.151\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 41/404: generic-array(build), proc-macro2(build), libc(build), libm(build), hashbrown, …\r"] +[0.024, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 42/404: generic-array(build), libc(build), libm(build), proc-macro2, hashbrown, serde_c…\r"] +[0.060, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 43/404: generic-array(build), libc(build), libm(build), proc-macro2, hashbrown, serde_c…\r"] +[0.012, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m futures-sink v0.3.33\r\n\u001b[1m\u001b[96m Building\u001b[0m [=> ] 44/404: generic-array(build), libc(build), libm(build), proc-macro2, futures-sink, hash…\r"] +[0.030, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m thiserror v2.0.19\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 45/404: generic-array(build), libc(build), libm(build), proc-macro2, hashbrown, serde_c…\r"] +[0.020, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=> ] 46/404: generic-array(build), libc(build), libm(build), proc-macro2, hashbrown, serde_c…\r"] +[0.057, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 47/404: generic-array(build), libc(build), libm(build), proc-macro2, serde_core, target…\r"] +[0.025, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 48/404: generic-array(build), libm, libc(build), proc-macro2, serde_core, target-lexico…\r"] +[0.012, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m stable_deref_trait v1.2.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 49/404: generic-array(build), libm, stable_deref_trait, libc(build), proc-macro2, serde…\r"] +[0.022, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 50/404: generic-array(build), libm, stable_deref_trait, libc(build), proc-macro2, serde…\r"] +[0.004, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m futures-channel v0.3.33\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 52/404: generic-array(build), libm, serde_json(build), libc(build), serde_core, target-…\r"] +[0.029, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m syn v2.0.119\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 53/404: syn, generic-array(build), libm, serde_json(build), libc(build), serde_core, ta…\r"] +[0.033, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m syn v3.0.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 54/404: syn, generic-array(build), libm, serde_json(build), libc(build), serde_core, ta…\r"] +[0.015, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m bumpalo v3.20.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 55/404: syn, generic-array(build), libm, serde_json(build), libc(build), serde_core, ta…\r"] +[0.079, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 56/404: libc, syn, generic-array(build), libm, serde_json(build), serde_core, target-le…\r"] +[0.015, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m slab v0.4.12\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 57/404: libc, syn, generic-array(build), libm, serde_json(build), serde_core, target-le…\r"] +[0.051, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m futures-task v0.3.33\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 58/404: libc, syn, generic-array(build), libm, serde_json(build), futures-task, serde_c…\r"] +[0.006, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-isle v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 59/404: libc, syn, generic-array(build), libm, serde_json(build), cranelift-isle(build.…\r"] +[0.010, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m futures-io v0.3.33\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[==> ] 60/404: libc, syn, generic-array(build), libm, serde_json(build), cranelift-isle(build.…"] +[0.000, "o", "\r"] +[0.043, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m crc32fast v1.5.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==> ] 61/404: libc, syn, generic-array(build), libm, serde_json(build), cranelift-isle(build.…\r"] +[0.008, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m indexmap v2.14.0\r\n\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[==> ] 62/404: libc, syn, generic-array(build), libm, serde_json(build), cranelift-isle(build.…\r"] +[0.039, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m semver v1.0.28\r\n\u001b[1m\u001b[96m Building\u001b[0m [===> ] 63/404: syn, generic-array(build), serde_json(build), cranelift-isle(build.rs), semver,…\r"] +[0.013, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m bytes v1.12.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 64/404: syn, generic-array(build), serde_json(build), cranelift-isle(build.rs), semver,…\r"] +[0.011, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m jobserver v0.1.35\r\n\u001b[1m\u001b[96m Building\u001b[0m [===> ] 65/404: syn, generic-array(build), serde_json(build), semver, bytes, target-lexicon(bui…\r"] +[0.010, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m errno v0.3.14\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 66/404: syn, generic-array(build), serde_json(build), semver, bytes, target-lexicon(bui…\r"] +[0.049, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cpufeatures v0.2.17\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 67/404: syn, generic-array(build), serde_json(build), semver, bytes, target-lexicon(bui…\r"] +[0.004, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 68/404: syn, generic-array(build), serde_json(build), semver, bytes, target-lexicon(bui…\r"] +[0.030, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cc v1.3.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 69/404: syn, generic-array(build), serde_json(build), semver, bytes, target-lexicon(bui…\r"] +[0.011, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 70/404: syn, serde_json(build), semver, bytes, target-lexicon(build), zmij(build), syn,…\r"] +[0.014, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 71/404: syn, serde_json(build), bytes, target-lexicon(build), zmij(build), syn, thiserr…\r"] +[0.018, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 72/404: syn, serde_json(build), bytes, target-lexicon(build), zmij(build), syn, thiserr…\r"] +[0.021, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m getrandom v0.4.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [===> ] 73/404: syn, serde_json(build), bytes, target-lexicon(build), getrandom(build.rs), zmij…\r"] +[0.017, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 74/404: syn, serde_json(build), bytes, getrandom(build.rs), zmij(build), syn, thiserror…\r"] +[0.014, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m gimli v0.33.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 75/404: syn, gimli, serde_json(build), bytes, getrandom(build.rs), zmij(build), syn, th…\r"] +[0.066, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-codegen-shared v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 76/404: syn, gimli, serde_json(build), getrandom(build.rs), zmij(build), syn, thiserror…\r"] +[0.011, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===> ] 77/404: syn, gimli, serde_json(build), getrandom(build.rs), syn, thiserror(build), zmij…\r"] +[0.027, "o", "\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m rand_core v0.10.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 78/404: syn, gimli, serde_json(build), rand_core, getrandom(build.rs), syn, thiserror(b…\r"] +[0.006, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m object v0.39.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 79/404: syn, gimli, serde_json(build), rand_core, object(build.rs), syn, thiserror(buil…\r"] +[0.015, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m crypto-common v0.1.6\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 80/404: syn, gimli, serde_json(build), rand_core, object(build.rs), syn, thiserror(buil…\r"] +[0.009, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m block-buffer v0.10.4\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 81/404: syn, gimli, serde_json(build), rand_core, object(build.rs), syn, thiserror(buil…\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 82/404: syn, gimli, serde_json(build), rand_core, object(build.rs), syn, thiserror(buil…\r"] +[0.001, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-assembler-x64 v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 83/404: syn, gimli, serde_json(build), rand_core, object(build.rs), syn, thiserror(buil…\r"] +[0.019, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 84/404: syn, gimli, serde_json(build), semver, object(build.rs), syn, thiserror(build),…\r"] +[0.003, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 85/404: syn, gimli, serde_json(build), semver, object(build.rs), syn, thiserror(build),…\r"] +[0.030, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m digest v0.10.7\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 86/404: syn, gimli, serde_json(build), semver, digest, object(build.rs), syn, thiserror…\r"] +[0.025, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 87/404: syn, gimli, serde_json(build), semver, digest, object(build.rs), syn, thiserror…\r"] +[0.006, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cpp_demangle v0.4.5\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 88/404: syn, gimli, serde_json(build), semver, digest, object(build.rs), syn, thiserror…\r"] +[0.016, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m crossbeam-utils v0.8.22\r\n\u001b[1m\u001b[96m Building\u001b[0m [====> ] 89/404: syn, gimli, serde_json(build), semver, digest, object(build.rs), syn, thiserror…\r"] +[0.013, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m sha2 v0.10.9\r\n\u001b[1m\u001b[96m Building\u001b[0m [====> ] 90/404: syn, gimli, serde_json(build), semver, digest, syn, thiserror(build), crc32fast…\r"] +[0.007, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 91/404: syn, gimli, serde_json(build), semver, syn, thiserror(build), crc32fast(build),…\r"] +[0.025, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m pkg-config v0.3.33\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 92/404: syn, gimli, serde_json(build), pkg-config, syn, thiserror(build), crc32fast(bui…\r"] +[0.004, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rustc-hash v2.1.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====> ] 93/404: syn, gimli, serde_json(build), pkg-config, syn, thiserror(build), rustc-hash, c…\r"] +[0.006, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 94/404: syn, gimli, serde_json(build), cranelift-assembler-x64(build), pkg-config, syn,…\r"] +[0.029, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 95/404: syn, gimli, serde_json(build), cranelift-assembler-x64(build), pkg-config, syn,…\r"] +[0.027, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m arbitrary v1.4.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 96/404: syn, gimli, cranelift-assembler-x64(build), pkg-config, syn, crc32fast(build), …\r"] +[0.004, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m io-lifetimes v2.0.4\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 97/404: syn, gimli, cranelift-assembler-x64(build), pkg-config, syn, crc32fast(build), …\r"] +[0.036, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 98/404: syn, gimli, cranelift-assembler-x64(build), pkg-config, syn, crossbeam-utils(bu…\r"] +[0.037, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 99/404: syn, gimli, cranelift-assembler-x64(build), pkg-config, syn, crossbeam-utils(bu…\r"] +[0.021, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 100/404: syn, gimli, cranelift-assembler-x64(build), pkg-config, syn, crossbeam-utils(bu…\r"] +[0.042, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m either v1.16.0\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 101/404: syn, gimli, cranelift-assembler-x64(build), pkg-config, syn, crossbeam-utils(bu…\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-component-util v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 102/404: syn, gimli, cranelift-assembler-x64(build), wasmtime-internal-component-util, s…\r"] +[0.018, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m termcolor v1.4.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[=====> ] 103/404: syn, gimli, cranelift-assembler-x64(build), wasmtime-internal-component-util, s…"] +[0.000, "o", "\r"] +[0.017, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 104/404: syn, gimli, cranelift-assembler-x64(build), wasmtime-internal-component-util, s…\r\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m crossbeam-epoch v0.9.20\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 105/404: syn, gimli, cranelift-assembler-x64(build), syn, crossbeam-utils(build), crossb…\r"] +[0.021, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zstd-sys v2.0.16+zstd.1.5.7\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 106/404: syn, gimli, cranelift-assembler-x64(build), syn, crossbeam-utils(build), crossb…\r"] +[0.012, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 107/404: syn, gimli, cranelift-assembler-x64(build), syn, crossbeam-utils(build), crossb…\r"] +[0.014, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m signal-hook-registry v1.4.8\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 108/404: syn, gimli, cranelift-assembler-x64(build), syn, crossbeam-utils(build), crossb…\r"] +[0.045, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 109/404: syn, gimli, cranelift-assembler-x64(build), syn, crossbeam-utils(build), crossb…\r"] +[0.035, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 110/404: syn, gimli, cranelift-assembler-x64(build), syn, crossbeam-utils(build), getran…\r"] +[0.020, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-control v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 111/404: syn, gimli, cranelift-control, cranelift-assembler-x64(build), syn, crossbeam-u…\r"] +[0.011, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m socket2 v0.6.5\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 112/404: syn, gimli, socket2, cranelift-control, cranelift-assembler-x64(build), syn, cr…\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m mio v1.2.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====> ] 113/404: syn, gimli, socket2, cranelift-control, cranelift-assembler-x64(build), syn, cr…\r"] +[0.028, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m synstructure v0.13.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 114/404: syn, gimli, socket2, cranelift-control, cranelift-assembler-x64(build), syn, cr…\r"] +[0.004, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 115/404: syn, gimli, socket2, cranelift-control, cranelift-assembler-x64(build), syn, cr…\r"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 116/404: syn, gimli, socket2, cranelift-assembler-x64(build), zstd-sys(build), syn, cros…\r"] +[0.013, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tracing-core v0.1.36\r\n\u001b[1m\u001b[96m Building\u001b[0m [======> ] 117/404: syn, gimli, tracing-core, socket2, cranelift-assembler-x64(build), zstd-sys(bui…\r"] +[0.165, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 118/404: syn, gimli, tracing-core, thiserror(build.rs), cranelift-assembler-x64(build), …\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 119/404: syn, gimli, crossbeam-utils, tracing-core, thiserror(build.rs), cranelift-assem…\r"] +[0.065, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rustc-demangle v0.1.28\r\n\u001b[1m\u001b[96m Building\u001b[0m [======> ] 120/404: syn, gimli, crossbeam-utils, tracing-core, thiserror(build.rs), cranelift-assem…\r"] +[0.004, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m crossbeam-deque v0.8.7\r\n\u001b[1m\u001b[96m Building\u001b[0m [======> ] 121/404: syn, gimli, crossbeam-utils, tracing-core, thiserror(build.rs), cranelift-assem…\r"] +[0.025, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 122/404: syn, gimli, crossbeam-utils, tracing-core, thiserror(build.rs), cranelift-assem…\r"] +[0.009, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m serde_derive v1.0.229\r\n\u001b[1m\u001b[96m Building\u001b[0m [======> ] 123/404: syn, gimli, crossbeam-utils, tracing-core, thiserror(build.rs), cranelift-assem…\r"] +[0.036, "o", "\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m thiserror-impl v2.0.19\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 124/404: syn, gimli, crossbeam-utils, thiserror(build.rs), cranelift-assembler-x64(build…\r"] +[0.046, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 125/404: syn, gimli, crossbeam-utils, thiserror(build.rs), cranelift-assembler-x64(build…\r"] +[0.020, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 126/404: syn, gimli, crossbeam-utils, thiserror(build), cranelift-assembler-x64(build), …\r"] +[0.045, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 127/404: syn, gimli, crossbeam-utils, thiserror(build), cranelift-assembler-x64(build), …\r"] +[0.006, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======> ] 128/404: syn, gimli, crossbeam-utils, thiserror(build), cranelift-assembler-x64(build), …\r"] +[0.014, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m io-extras v0.18.4\r\n\u001b[1m\u001b[96m Building\u001b[0m [======> ] 129/404: syn, gimli, thiserror(build), cranelift-assembler-x64(build), zstd-sys(build), …\r"] +[0.008, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 130/404: syn, gimli, thiserror(build), cranelift-assembler-x64(build), zstd-sys(build), …\r"] +[0.024, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m litemap v0.8.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 131/404: syn, gimli, litemap, thiserror(build), cranelift-assembler-x64(build), zstd-sys…\r"] +[0.077, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m writeable v0.6.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 132/404: syn, gimli, thiserror(build), cranelift-assembler-x64(build), writeable, zstd-s…\r"] +[0.066, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zstd-safe v7.2.4\r\n\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 134/404: syn, gimli, cranelift-assembler-x64, thiserror(build), writeable, zstd-sys(buil…\r"] +[0.028, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rayon-core v1.13.0\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 135/404: syn, gimli, cranelift-assembler-x64, thiserror(build), writeable, zstd-sys(buil…\r"] +[0.004, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 136/404: syn, gimli, cranelift-assembler-x64, thiserror(build), io-extras(build), zstd-s…\r"] +[0.039, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m uuid v1.24.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 137/404: syn, gimli, cranelift-assembler-x64, io-extras(build), uuid, zstd-sys(build), c…\r"] +[0.056, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 138/404: syn, gimli, cranelift-assembler-x64, io-extras(build), uuid, zstd-sys(build), c…\r"] +[0.033, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 139/404: syn, gimli, cranelift-assembler-x64, io-extras(build), uuid, zstd-sys(build), c…\r"] +[0.047, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zerofrom-derive v0.1.7\r\n\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 140/404: gimli, cranelift-assembler-x64, io-extras(build), uuid, zstd-sys(build), cpp_de…\r"] +[0.019, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m yoke-derive v0.8.2\r\n\u001b[1m\u001b[92m Compiling\u001b[0m pulley-macros v45.0.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 142/404: gimli, cranelift-assembler-x64, io-extras(build), uuid, zstd-sys(build), cpp_de…\r"] +[0.030, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m futures-macro v0.3.33\r\n\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 143/404: gimli, cranelift-assembler-x64, futures-macro, io-extras(build), uuid, zstd-sys…\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-versioned-export-macros v45.0.3\r\n\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[=======> ] 144/404: gimli, cranelift-assembler-x64, futures-macro, uuid, zstd-sys(build), cpp_deman…\r"] +[0.036, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zerovec-derive v0.11.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======> ] 145/404: gimli, cranelift-assembler-x64, futures-macro, zerovec-derive, uuid, zstd-sys(b…\r"] +[0.016, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m displaydoc v0.2.6\r\n"] +[0.002, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 146/404: gimli, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sys(build),…\r"] +[0.069, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 147/404: gimli, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sys(build),…\r"] +[0.072, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tokio-macros v2.7.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [========> ] 148/404: cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sys(build), cpp_de…\r"] +[0.030, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zeroize_derive v1.5.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 149/404: zeroize_derive, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sy…\r"] +[0.017, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tracing-attributes v0.1.31\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 150/404: zeroize_derive, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sy…\r"] +[0.017, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 151/404: zeroize_derive, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sy…\r"] +[0.027, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cobs v0.3.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [========> ] 152/404: zeroize_derive, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sy…\r"] +[0.014, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 153/404: zeroize_derive, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sy…\r"] +[0.016, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasm-encoder v0.254.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 154/404: zeroize_derive, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sy…\r"] +[0.060, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m icu_normalizer_data v2.2.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 155/404: zeroize_derive, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sy…\r"] +[0.038, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m id-arena v2.3.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [========> ] 156/404: zeroize_derive, cranelift-assembler-x64, futures-macro, zerovec-derive, zstd-sy…\r"] +[0.017, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m futures-util v0.3.33\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 157/404: zeroize_derive, cranelift-assembler-x64, zerovec-derive, futures-util, zstd-sys…\r"] +[0.043, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m unicode-xid v0.2.6\r\n\u001b[1m\u001b[96m Building\u001b[0m [========> ] 158/404: zeroize_derive, cranelift-assembler-x64, zerovec-derive, futures-util, zstd-sys…\r"] +[0.016, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m utf8_iter v1.0.4\r\n\u001b[1m\u001b[96m Building\u001b[0m [========> ] 159/404: zeroize_derive, cranelift-assembler-x64, zerovec-derive, futures-util, zstd-sys…\r"] +[0.038, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m winnow v1.0.4\r\n\u001b[1m\u001b[96m Building\u001b[0m [========> ] 160/404: winnow, zeroize_derive, cranelift-assembler-x64, zerovec-derive, futures-util, …\r"] +[0.020, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-unwinder v45.0.3\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [========> ] 161/404: winnow, zeroize_derive, cranelift-assembler-x64, zerovec-derive, futures-util, …\r"] +[0.012, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zeroize v1.9.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 162/404: winnow, cranelift-assembler-x64, zerovec-derive, futures-util, zstd-sys(build),…\r"] +[0.010, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m unicode-width v0.2.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 163/404: winnow, cranelift-assembler-x64, zerovec-derive, futures-util, zstd-sys(build),…\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 164/404: winnow, cranelift-assembler-x64, zerovec-derive, futures-util, zstd-sys(build),…\r"] +[0.070, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tokio v1.53.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 165/404: winnow, cranelift-assembler-x64, zerovec-derive, futures-util, zstd-sys(build),…\r"] +[0.024, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m icu_properties_data v2.2.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 166/404: winnow, cranelift-assembler-x64, zerovec-derive, futures-util, zstd-sys(build),…\r"] +[0.013, "o", "\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m thiserror v1.0.69\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 167/404: winnow, cranelift-assembler-x64, futures-util, zstd-sys(build), unicode-width, …\r"] +[0.019, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zerofrom v0.1.8\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m cap-primitives v3.4.5\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 169/404: cranelift-assembler-x64, futures-util, zstd-sys(build), unicode-width, object(b…\r"] +[0.023, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wast v254.0.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 170/404: cranelift-assembler-x64, futures-util, zstd-sys(build), wast, object(build.rs),…\r"] +[0.027, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 171/404: cranelift-assembler-x64, wasmtime-internal-unwinder(build), futures-util, zstd-…\r"] +[0.021, "o", "\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m toml_parser v1.1.2+spec-1.1.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 172/404: cranelift-assembler-x64, wasmtime-internal-unwinder(build), futures-util, zstd-…\r"] +[0.015, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 173/404: cranelift-assembler-x64, wasmtime-internal-unwinder(build), futures-util, zstd-…\r"] +[0.020, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 174/404: cranelift-assembler-x64, icu_properties_data(build), wasmtime-internal-unwinder…\r"] +[0.068, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 175/404: cranelift-assembler-x64, icu_properties_data(build), wasmtime-internal-unwinder…\r"] +[0.003, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 176/404: cranelift-assembler-x64, icu_properties_data(build), wasmtime-internal-unwinder…\r"] +[0.012, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m yoke v0.8.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=========> ] 177/404: cranelift-assembler-x64, icu_properties_data(build), wasmtime-internal-unwinder…\r\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 178/404: cranelift-assembler-x64, icu_properties_data(build), cap-primitives(build), was…\r"] +[0.024, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 179/404: cranelift-assembler-x64, icu_properties_data(build), cap-primitives(build), was…\r"] +[0.015, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tracing v0.1.44\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 180/404: cranelift-assembler-x64, icu_properties_data(build), cap-primitives(build), was…\r"] +[0.019, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 181/404: cranelift-assembler-x64, icu_properties_data(build), cap-primitives(build), yok…\r"] +[0.099, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 182/404: cranelift-assembler-x64, icu_properties_data(build), cap-primitives(build), yok…\r"] +[0.060, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 183/404: cranelift-assembler-x64, icu_properties_data(build), cap-primitives(build), yok…\r"] +[0.062, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-fiber v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 184/404: cranelift-assembler-x64, icu_properties_data(build), cap-primitives(build), yok…\r"] +[0.059, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-jit-debug v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 185/404: cranelift-assembler-x64, icu_properties_data(build), cap-primitives(build), yok…\r"] +[0.006, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 186/404: cranelift-assembler-x64, io-extras, cap-primitives(build), yoke, futures-util, …\r"] +[0.047, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m thiserror-impl v1.0.69\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 187/404: cranelift-assembler-x64, thiserror-impl, cap-primitives(build), yoke, futures-u…\r"] +[0.105, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m fs-set-times v0.20.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 188/404: cranelift-assembler-x64, thiserror-impl, cap-primitives(build), yoke, futures-u…\r"] +[0.022, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m ring v0.17.14\r\n\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 189/404: ring(build.rs), cranelift-assembler-x64, thiserror-impl, yoke, futures-util, zs…\r"] +[0.031, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m dirs-sys-next v0.1.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 190/404: ring(build.rs), cranelift-assembler-x64, thiserror-impl, yoke, futures-util, zs…\r"] +[0.044, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m serde_spanned v1.1.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[==========> ] 191/404: ring(build.rs), serde_spanned, cranelift-assembler-x64, thiserror-impl, yoke, f…\r"] +[0.047, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m toml_datetime v0.7.5+spec-1.1.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 192/404: ring(build.rs), cranelift-assembler-x64, thiserror-impl, yoke, futures-util, zs…\r"] +[0.059, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zerovec v0.11.6\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==========> ] 193/404: ring(build.rs), cranelift-assembler-x64, thiserror-impl, zerovec, futures-util,…\r"] +[0.037, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m smallvec v1.15.2\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 194/404: ring(build.rs), cranelift-assembler-x64, thiserror-impl, zerovec, futures-util,…\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 195/404: ring(build.rs), cranelift-assembler-x64, thiserror-impl, zerovec, futures-util,…\r"] +[0.004, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 196/404: ring(build.rs), cranelift-assembler-x64, thiserror-impl, zerovec, smallvec, zst…\r"] +[0.003, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmparser v0.248.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 197/404: cranelift-assembler-x64, thiserror-impl, zerovec, smallvec, zstd-sys(build), wa…\r"] +[0.025, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 198/404: cranelift-assembler-x64, thiserror-impl, zerovec, smallvec, zstd-sys(build), wa…\r"] +[0.004, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m postcard v1.1.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 199/404: cranelift-assembler-x64, thiserror-impl, zerovec, smallvec, zstd-sys(build), wa…\r"] +[0.051, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m regalloc2 v0.15.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 200/404: cranelift-assembler-x64, thiserror-impl, zerovec, zstd-sys(build), wast, wasmpa…"] +[0.000, "o", "\r"] +[0.016, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m futures-executor v0.3.33\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 201/404: cranelift-assembler-x64, thiserror-impl, zerovec, zstd-sys(build), wast, wasmpa…\r"] +[0.070, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m futures v0.3.33\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 202/404: futures, cranelift-assembler-x64, thiserror-impl, zerovec, zstd-sys(build), was…"] +[0.000, "o", "\r"] +[0.011, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zerotrie v0.2.4\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 203/404: futures, cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparser, w…\r"] +[0.025, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-bitset v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 204/404: cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparser, wasmtime-i…\r"] +[0.020, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 205/404: cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparser, wasmtime-i…\r"] +[0.013, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m fixedbitset v0.4.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 206/404: fixedbitset, cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparse…"] +[0.000, "o", "\r"] +[0.027, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m ambient-authority v0.0.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 207/404: fixedbitset, cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparse…\r"] +[0.029, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m pulley-interpreter v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 208/404: fixedbitset, cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparse…\r\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-entity v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 209/404: fixedbitset, cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparse…\r"] +[0.014, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===========> ] 210/404: fixedbitset, cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparse…\r"] +[0.007, "o", "\u001b[1m\u001b[96m Building\u001b[0m [============> ] 211/404: fixedbitset, cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparse…\r"] +[0.012, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m toml_writer v1.1.2+spec-1.1.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [============> ] 212/404: cranelift-assembler-x64, zerovec, zstd-sys(build), wast, wasmparser, pulley-int…\r"] +[0.113, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m leb128 v0.2.7\r\n\u001b[1m\u001b[96m Building\u001b[0m [============> ] 213/404: cranelift-assembler-x64, leb128, zerovec, zstd-sys(build), wast, wasmparser, pu…\r"] +[0.020, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m autocfg v1.5.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [============> ] 214/404: cranelift-assembler-x64, leb128, zerovec, autocfg, zstd-sys(build), wast, wasmp…\r"] +[0.005, "o", "\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-component-macro v45.0.3\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [============> ] 215/404: cranelift-assembler-x64, wasmtime-internal-component-macro(build.rs), leb128, z…\r"] +[0.016, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-cranelift v45.0.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [============> ] 216/404: wasmtime-internal-cranelift(build.rs), cranelift-assembler-x64, wasmtime-intern…\r"] +[0.049, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-bforest v0.132.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [============> ] 217/404: wasmtime-internal-cranelift(build.rs), cranelift-assembler-x64, wasmtime-intern…\r"] +[0.009, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m maybe-owned v0.3.4\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [============> ] 218/404: wasmtime-internal-cranelift(build.rs), cranelift-assembler-x64, wasmtime-intern…\r"] +[0.096, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-cache v45.0.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [============> ] 219/404: wasmtime-internal-cranelift(build.rs), cranelift-assembler-x64, wasmtime-intern…\r"] +[0.010, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m ipnet v2.12.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [============> ] 220/404: wasmtime-internal-cranelift(build.rs), cranelift-assembler-x64, zerovec, ipnet,…\r"] +[0.010, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m winnow v0.7.15\r\n\u001b[1m\u001b[96m Building\u001b[0m [============> ] 221/404: cranelift-assembler-x64, zerovec, ipnet, autocfg, zstd-sys(build), wast, wasmpa…\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m num-traits v0.2.19\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [============> ] 223/404: cranelift-assembler-x64, zerovec, ipnet, zstd-sys(build), wast, wasmparser, pul…\r"] +[0.021, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tinystr v0.8.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [============> ] 224/404: cranelift-assembler-x64, zerovec, ipnet, zstd-sys(build), wast, wasmparser, pul…\r"] +[0.032, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m potential_utf v0.1.5\r\n\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[============> ] 225/404: potential_utf, cranelift-assembler-x64, ipnet, zstd-sys(build), wast, wasmparse…\r"] +[0.052, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m icu_locale_core v2.2.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [============> ] 226/404: potential_utf, cranelift-assembler-x64, ipnet, zstd-sys(build), wast, wasmparse…\r"] +[0.009, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m icu_collections v2.2.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 227/404: potential_utf, cranelift-assembler-x64, icu_collections, ipnet, zstd-sys(build)…\r"] +[0.023, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 228/404: cranelift-assembler-x64, icu_collections, ipnet, zstd-sys(build), wast, wasmpar…\r"] +[0.016, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 229/404: cranelift-assembler-x64, icu_collections, ipnet, zstd-sys(build), wast, wasmpar…\r"] +[0.025, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 230/404: cranelift-assembler-x64, icu_collections, ipnet, zstd-sys(build), wast, wasmpar…\r"] +[0.091, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wast v35.0.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 231/404: cranelift-assembler-x64, icu_collections, ipnet, zstd-sys(build), wast, wasmpar…\r"] +[0.027, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 232/404: cranelift-assembler-x64, icu_collections, zstd-sys(build), wast, wasmparser, pu…\r"] +[0.086, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 233/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, pulley-interpreter,…\r"] +[0.010, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 234/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, pulley-interpreter,…\r"] +[0.034, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-codegen-meta v0.132.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 235/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, pulley-interpreter,…\r"] +[0.102, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m icu_provider v2.2.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 236/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, pulley-interpreter,…\r"] +[0.021, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wat v1.254.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 237/404: cranelift-assembler-x64, wat, zstd-sys(build), wast, wasmparser, pulley-interpr…\r"] +[0.014, "o", "\u001b[K"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m petgraph v0.6.5\r\n\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 238/404: cranelift-assembler-x64, wat, zstd-sys(build), wast, wasmparser, pulley-interpr…\r"] +[0.007, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 239/404: cranelift-assembler-x64, wat, zstd-sys(build), wast, wasmparser, thiserror, pul…\r"] +[0.088, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 240/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, thiserror, pulley-i…\r"] +[0.017, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 241/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, thiserror, pulley-i…\r"] +[0.017, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=============> ] 242/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, thiserror, pulley-i…"] +[0.001, "o", "\r"] +[0.041, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m toml v0.9.12+spec-1.1.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 243/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, pulley-interpreter,…\r"] +[0.032, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 244/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, pulley-interpreter,…\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m directories-next v2.0.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 245/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, pulley-interpreter,…\r"] +[0.003, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 246/404: cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, pulley-interpreter,…\r"] +[0.012, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rayon v1.12.0\r\n\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[==============> ] 247/404: rayon, cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, pulley-inter…"] +[0.000, "o", "\r"] +[0.003, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 248/404: rayon, cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, object, pull…\r"] +[0.074, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rustls-pki-types v1.15.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 249/404: rayon, cranelift-assembler-x64, zstd-sys(build), wast, wasmparser, object, pull…\r"] +[0.041, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 250/404: rayon, cranelift-assembler-x64, wasmtime(build.rs), zstd-sys(build), wast, wasm…\r"] +[0.022, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m debugid v0.8.0\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[==============> ] 251/404: rayon, cranelift-assembler-x64, wasmtime(build.rs), zstd-sys(build), wast, wasm…"] +[0.000, "o", "\r"] +[0.090, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m async-trait v0.1.91\r\n\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 252/404: rayon, cranelift-assembler-x64, wasmtime(build.rs), async-trait, zstd-sys(build…\r"] +[0.062, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m itertools v0.14.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[==============> ] 253/404: rayon, cranelift-assembler-x64, async-trait, itertools, zstd-sys(build), wast, …\r"] +[0.029, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m fastrand v2.5.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 254/404: rayon, cranelift-assembler-x64, async-trait, itertools, zstd-sys(build), wast, …\r"] +[0.107, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m core-foundation-sys v0.8.7\r\n\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 255/404: rayon, cranelift-assembler-x64, async-trait, itertools, zstd-sys(build), wast, …\r"] +[0.096, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cap-std v3.4.5\r\n\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 256/404: rayon, cranelift-assembler-x64, async-trait, itertools, zstd-sys(build), wast, …\r"] +[0.009, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 257/404: rayon, cranelift-assembler-x64, async-trait, itertools, zstd-sys(build), wast, …\r"] +[0.128, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m base64 v0.22.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==============> ] 258/404: rayon, async-trait, itertools, zstd-sys(build), wast, wasmparser, object, pulle…\r"] +[0.005, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m iana-time-zone v0.1.65\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 259/404: iana-time-zone, rayon, async-trait, itertools, zstd-sys(build), wast, wasmparse…\r"] +[0.069, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tempfile v3.27.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 260/404: rayon, async-trait, itertools, zstd-sys(build), wast, wasmparser, pulley-interp…\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 261/404: rayon, async-trait, itertools, zstd-sys(build), wast, wasmparser, pulley-interp…\r"] +[0.076, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 262/404: rayon, async-trait, itertools, zstd-sys(build), wast, wasmparser, pulley-interp…\r"] +[0.012, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m fxprof-processed-profile v0.8.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 263/404: rayon, itertools, zstd-sys(build), wast, wasmparser, pulley-interpreter, cranel…\r"] +[0.018, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m icu_properties v2.2.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 264/404: rayon, icu_properties, itertools, zstd-sys(build), wast, wasmparser, pulley-int…\r"] +[0.029, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 265/404: rayon, wasmtime-internal-jit-debug, icu_properties, itertools, zstd-sys(build),…\r"] +[0.047, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m witx v0.9.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 266/404: rayon, wasmtime-internal-jit-debug, icu_properties, witx, itertools, zstd-sys(b…\r"] +[0.017, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m icu_normalizer v2.2.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 267/404: wasmtime-internal-jit-debug, icu_properties, witx, itertools, zstd-sys(build), …\r"] +[0.011, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 268/404: num-traits(build), icu_properties, witx, itertools, zstd-sys(build), wast, wasm…\r"] +[0.010, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-jit-icache-coherence v45.0.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 269/404: num-traits(build), icu_properties, witx, zstd-sys(build), wast, wasmparser, was…\r"] +[0.045, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasm-encoder v0.248.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 270/404: num-traits(build), wasm-encoder, icu_properties, witx, zstd-sys(build), wast, w…\r"] +[0.008, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmprinter v0.248.0\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 271/404: num-traits(build), wasm-encoder, wasmprinter, icu_properties, witx, zstd-sys(bu…\r"] +[0.120, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wit-parser v0.248.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 272/404: wit-parser, wasm-encoder, wasmprinter, icu_properties, witx, zstd-sys(build), w…\r"] +[0.075, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-codegen v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 273/404: wit-parser, wasm-encoder, wasmprinter, icu_properties, witx, zstd-sys(build), w…\r"] +[0.112, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m addr2line v0.26.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [===============> ] 274/404: wit-parser, wasm-encoder, wasmprinter, icu_properties, witx, zstd-sys(build), w…\r"] +[0.008, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m http v1.4.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [================> ] 275/404: wit-parser, wasm-encoder, wasmprinter, icu_properties, witx, zstd-sys(build), w…\r"] +[0.184, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m getrandom v0.2.17\r\n\u001b[1m\u001b[96m Building\u001b[0m [================> ] 276/404: wit-parser, wasm-encoder, wasmprinter, icu_properties, witx, zstd-sys(build), w…\r\u001b[1m\u001b[96m Building\u001b[0m [================> ] 277/404: wit-parser, wasm-encoder, wasmprinter, icu_properties, witx, cranelift-codegen(…\r"] +[0.016, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-environ v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [================> ] 278/404: wit-parser, wasm-encoder, wasmprinter, witx, cranelift-codegen(build), zstd-sys…\r"] +[0.046, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m mach2 v0.4.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [================> ] 279/404: wit-parser, wasm-encoder, wasmprinter, witx, cranelift-codegen(build), zstd-sys…\r"] +[0.094, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasm-compose v0.248.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [================> ] 280/404: wit-parser, wasm-encoder, wasmprinter, witx, cranelift-codegen(build), zstd-sys…\r"] +[0.045, "o", "\u001b[1m\u001b[96m Building\u001b[0m [================> ] 281/404: wit-parser, wasm-encoder, wasmprinter, witx, cranelift-codegen(build), zstd-sys…\r"] +[0.023, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m encoding_rs v0.8.35\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [================> ] 282/404: wit-parser, wasm-encoder, wasmprinter, witx, cranelift-codegen(build), zstd-sys…"] +[0.000, "o", "\r"] +[0.110, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zerocopy v0.8.55\r\n\u001b[1m\u001b[96m Building\u001b[0m [================> ] 283/404: wit-parser, wasm-encoder, wasmprinter, zerocopy(build.rs), cranelift-codegen(bu…\r"] +[0.009, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m untrusted v0.9.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [================> ] 284/404: wit-parser, wasm-encoder, wasmprinter, zerocopy(build.rs), cranelift-codegen(bu…\r"] +[0.047, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wiggle-macro v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [================> ] 285/404: wit-parser, wasm-encoder, wasmprinter, zerocopy(build.rs), cranelift-codegen(bu…\r"] +[0.114, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m idna_adapter v1.2.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [================> ] 286/404: wit-parser, wasm-encoder, wasmprinter, zerocopy(build.rs), cranelift-codegen(bu…\r"] +[0.057, "o", "\u001b[1m\u001b[96m Building\u001b[0m [================> ] 287/404: wit-parser, wasm-encoder, wasmprinter, zerocopy(build.rs), cranelift-codegen(bu…\r"] +[0.009, "o", "\u001b[1m\u001b[96m Building\u001b[0m [================> ] 288/404: wit-parser, num-traits, wasmprinter, zerocopy(build.rs), cranelift-codegen(buil…\r"] +[0.034, "o", "\u001b[1m\u001b[96m Building\u001b[0m [================> ] 289/404: wit-parser, num-traits, wasmprinter, zerocopy(build.rs), cranelift-codegen(buil…\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [================> ] 290/404: wit-parser, num-traits, zerocopy(build), wasmprinter, cranelift-codegen(build),…\r"] +[0.075, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tokio-util v0.7.19\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 291/404: wit-parser, num-traits, zerocopy(build), wasmprinter, cranelift-codegen(build),…\r"] +[0.047, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zerocopy-derive v0.8.55\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 292/404: wit-parser, num-traits, zerocopy(build), wasmprinter, cranelift-codegen(build),…\r"] +[0.060, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-internal-wit-bindgen v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 293/404: wit-parser, num-traits, wasmtime-internal-wit-bindgen, wasmprinter, cranelift-c…\r"] +[0.108, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m regex-syntax v0.8.11\r\n\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 294/404: wit-parser, wasmtime-internal-wit-bindgen, wasmprinter, cranelift-codegen(build…\r"] +[0.143, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m httparse v1.10.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 295/404: wit-parser, wasmtime-internal-wit-bindgen, httparse(build.rs), cranelift-codege…\r"] +[0.194, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rustversion v1.0.23\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 296/404: wit-parser, wasmtime-internal-wit-bindgen, cranelift-codegen(build), zstd-sys(b…\r"] +[0.081, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m percent-encoding v2.3.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 297/404: wit-parser, wasmtime-internal-wit-bindgen, cranelift-codegen(build), zstd-sys(b…\r"] +[0.000, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cap-fs-ext v3.4.5\r\n"] +[0.004, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 298/404: wit-parser, wasmtime-internal-wit-bindgen, cranelift-codegen(build), cap-fs-ext…\r"] +[0.074, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rustls v0.23.42\r\n\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 299/404: wit-parser, wasmtime-internal-wit-bindgen, cranelift-codegen(build), cap-fs-ext…\r"] +[0.046, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 300/404: wit-parser, wasmtime-internal-wit-bindgen, cranelift-codegen(build), cap-fs-ext…\r"] +[0.010, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m form_urlencoded v1.2.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 301/404: wit-parser, wasmtime-internal-wit-bindgen, cranelift-codegen(build), cap-fs-ext…\r"] +[0.030, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 302/404: wit-parser, wasmtime-internal-wit-bindgen, cranelift-codegen(build), zstd-sys(b…\r"] +[0.009, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m regex-automata v0.4.16\r\n\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 303/404: wit-parser, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy-der…\r"] +[0.026, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 304/404: wit-parser, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy-der…\r"] +[0.018, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m idna v1.1.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 305/404: wit-parser, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy-der…\r"] +[0.043, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 306/404: wit-parser, cranelift-codegen(build), zstd-sys(build), wasmparser, pulley-inter…\r"] +[0.006, "o", "\u001b[K"] +[0.001, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m http-body v1.1.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=================> ] 307/404: wit-parser, cranelift-codegen(build), zstd-sys(build), wasmparser, pulley-inter…\r"] +[0.069, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m chacha20 v0.10.1\r\n\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[==================> ] 308/404: wit-parser, chacha20, cranelift-codegen(build), zstd-sys(build), wasmparser, pu…\r"] +[0.006, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m try-lock v0.2.5\r\n\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 309/404: wit-parser, chacha20, cranelift-codegen(build), zstd-sys(build), wasmparser, ze…\r"] +[0.028, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m subtle v2.6.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 310/404: wit-parser, chacha20, cranelift-codegen(build), zstd-sys(build), wasmparser, ze…\r"] +[0.031, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m atomic-waker v1.1.2\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 311/404: wit-parser, chacha20, cranelift-codegen(build), atomic-waker, zstd-sys(build), …\r"] +[0.033, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rand v0.10.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 312/404: wit-parser, chacha20, cranelift-codegen(build), atomic-waker, zstd-sys(build), …\r"] +[0.015, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m h2 v0.4.15\r\n\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 313/404: wit-parser, cranelift-codegen(build), atomic-waker, zstd-sys(build), wasmparser…\r"] +[0.005, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m want v0.3.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 314/404: wit-parser, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, re…\r"] +[0.009, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 315/404: wit-parser, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, re…\r"] +[0.068, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m url v2.5.8\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 317/404: wit-parser, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, re…\r"] +[0.067, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 318/404: wit-parser, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, re…\r"] +[0.095, "o", "\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[==================> ] 319/404: cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, regex-automata…"] +[0.000, "o", "\r"] +[0.049, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 320/404: cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, regex-automata…\r"] +[0.008, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cap-net-ext v3.4.5\r\n\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 321/404: cap-net-ext, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, r…\r"] +[0.005, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cap-time-ext v3.4.5\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 322/404: cap-time-ext, cap-net-ext, cranelift-codegen(build), zstd-sys(build), wasmparse…\r"] +[0.013, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wiggle-generate v45.0.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [==================> ] 323/404: cap-time-ext, cap-net-ext, cranelift-codegen(build), zstd-sys(build), wasmparse…\r"] +[0.036, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m webpki-roots v1.0.9\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 324/404: cap-time-ext, webpki-roots, cap-net-ext, cranelift-codegen(build), zstd-sys(bui…\r"] +[0.010, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m ciborium-io v0.2.2\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 325/404: webpki-roots, ciborium-io, cap-net-ext, cranelift-codegen(build), zstd-sys(buil…\r"] +[0.019, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m httpdate v1.0.3\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 326/404: webpki-roots, ciborium-io, cranelift-codegen(build), zstd-sys(build), wasmparse…\r"] +[0.010, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m plotters-backend v0.3.7\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 327/404: ciborium-io, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, r…\r"] +[0.006, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m clap_lex v1.1.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 328/404: clap_lex, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, rege…\r"] +[0.090, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m unsafe-libyaml v0.2.11\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 329/404: clap_lex, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, rege…\r"] +[0.020, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m ryu v1.0.23\r\n\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 330/404: clap_lex, cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, rege…\r"] +[0.014, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m anstyle v1.0.14\r\n\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 331/404: cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, regex-automata…\r"] +[0.057, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m plotters-svg v0.3.7\r\n\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 332/404: cranelift-codegen(build), zstd-sys(build), wasmparser, zerocopy, regex-automata…\r"] +[0.057, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m rustls-webpki v0.103.13\r\n\u001b[1m\u001b[92m Compiling\u001b[0m clap_builder v4.6.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 334/404: rustls-webpki, cranelift-codegen(build), zstd-sys(build), clap_builder, wasmpar…\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m webpki-roots v0.26.11\r\n\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 335/404: rustls-webpki, cranelift-codegen(build), zstd-sys(build), clap_builder, wasmpar…\r"] +[0.016, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m arc-swap v1.9.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 336/404: rustls-webpki, cranelift-codegen(build), zstd-sys(build), clap_builder, wasmpar…\r"] +[0.022, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m http-body-util v0.1.4\r\n\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 337/404: rustls-webpki, cranelift-codegen(build), zstd-sys(build), clap_builder, wasmpar…\r"] +[0.014, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m chrono v0.4.45\r\n\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 338/404: rustls-webpki, chrono, cranelift-codegen(build), zstd-sys(build), clap_builder,…\r"] +[0.028, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cpex-orchestration v0.2.2 (/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/crates/cpex-orchestration)\r\n\u001b[1m\u001b[96m Building\u001b[0m [===================> ] 339/404: rustls-webpki, chrono, cpex-orchestration, cranelift-codegen(build), zstd-sys(b…\r"] +[0.088, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m serde_yaml v0.9.34+deprecated\r\n\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 340/404: rustls-webpki, chrono, cranelift-codegen(build), zstd-sys(build), clap_builder,…\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m itertools v0.10.5\r\n\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 341/404: rustls-webpki, chrono, itertools, cranelift-codegen(build), zstd-sys(build), cl…\r"] +[0.021, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 342/404: rustls-webpki, chrono, itertools, cranelift-codegen(build), clap_builder, wasmp…\r"] +[0.026, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 343/404: rustls-webpki, chrono, itertools, zstd-sys, cranelift-codegen(build), clap_buil…\r"] +[0.060, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 344/404: rustls-webpki, chrono, itertools, zstd-sys, cranelift-codegen(build), clap_buil…\r"] +[0.019, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 345/404: zstd-safe, rustls-webpki, chrono, itertools, zstd-sys, cranelift-codegen(build)…\r"] +[0.002, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 346/404: zstd-safe, rustls-webpki, chrono, itertools, cranelift-codegen(build), clap_bui…\r"] +[0.039, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m lazy_static v1.5.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 347/404: zstd-safe, rustls-webpki, chrono, itertools, cranelift-codegen(build), clap_bui…\r"] +[0.069, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m zstd v0.13.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 348/404: zstd-safe, rustls-webpki, chrono, itertools, cranelift-codegen(build), clap_bui…\r"] +[0.007, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 349/404: zstd-safe, rustls-webpki, chrono, itertools, cranelift-codegen(build), clap_bui…\r"] +[0.018, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m hyper v1.11.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 350/404: rustls-webpki, chrono, itertools, cranelift-codegen(build), clap_builder, zeroc…\r"] +[0.118, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m same-file v1.0.6\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 351/404: rustls-webpki, chrono, itertools, cranelift-codegen(build), clap_builder, zeroc…\r"] +[0.040, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wildmatch v2.6.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 352/404: rustls-webpki, chrono, itertools, cranelift-codegen(build), clap_builder, zeroc…\r"] +[0.056, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cast v0.3.0\r\n\u001b[1m\u001b[92m Compiling\u001b[0m clap v4.6.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 354/404: chrono, itertools, cranelift-codegen(build), cast, clap_builder, zerocopy, rege…\r"] +[0.030, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m walkdir v2.5.0\r\n"] +[0.003, "o", "\u001b[1m\u001b[96m Building\u001b[0m [====================> ] 355/404: chrono, walkdir, itertools, cranelift-codegen(build), cast, clap_builder, zeroc…\r"] +[0.006, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 356/404: chrono, walkdir, itertools, cast, clap_builder, zerocopy, regex-automata, serde…\r"] +[0.023, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cpex-core v0.2.2 (/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/crates/cpex-core)\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 357/404: chrono, walkdir, itertools, cast, clap_builder, zerocopy, regex-automata, cpex-…\r"] +[0.025, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m criterion-plot v0.5.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 358/404: chrono, walkdir, itertools, cast, clap_builder, zerocopy, regex-automata, cpex-…\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m sharded-slab v0.1.7\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 359/404: sharded-slab, chrono, walkdir, cast, clap_builder, zerocopy, regex-automata, cp…\r"] +[0.005, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m regex v1.13.1\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 360/404: sharded-slab, chrono, walkdir, clap_builder, zerocopy, regex-automata, cpex-cor…\r"] +[0.135, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m half v2.7.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 361/404: sharded-slab, chrono, half, clap_builder, zerocopy, regex-automata, cpex-core, …\r"] +[0.028, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m matchers v0.2.0\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 362/404: sharded-slab, chrono, half, clap_builder, matchers, regex-automata, cpex-core, …\r"] +[0.060, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m plotters v0.3.7\r\n"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 363/404: sharded-slab, chrono, plotters, half, clap_builder, regex-automata, cpex-core, …\r"] +[0.003, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tinytemplate v1.2.1\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 364/404: chrono, plotters, half, clap_builder, regex-automata, cpex-core, serde_yaml, wa…\r"] +[0.006, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tracing-log v0.2.0\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 365/404: plotters, half, clap_builder, regex-automata, cpex-core, tracing-log, serde_yam…\r"] +[0.005, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m is-terminal v0.4.17\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 366/404: plotters, half, clap_builder, regex-automata, cpex-core, is-terminal, tracing-l…\r"] +[0.007, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m thread_local v1.1.10\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 367/404: plotters, thread_local, half, clap_builder, regex-automata, cpex-core, is-termi…\r"] +[0.038, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m ciborium-ll v0.2.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 368/404: ciborium-ll, plotters, thread_local, half, clap_builder, regex-automata, cpex-c…\r"] +[0.009, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m anes v0.1.6\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 369/404: ciborium-ll, plotters, thread_local, clap_builder, regex-automata, cpex-core, t…\r"] +[0.048, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m oorandom v11.1.5\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 370/404: ciborium-ll, plotters, thread_local, clap_builder, regex-automata, cpex-core, s…\r"] +[0.051, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m ciborium v0.2.2\r\n\u001b[1m\u001b[96m Building\u001b[0m [=====================> ] 371/404: ciborium-ll, plotters, clap_builder, ciborium, regex-automata, cpex-core, serde…\r"] +[0.020, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m nu-ansi-term v0.50.3\r\n\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 372/404: ciborium-ll, plotters, clap_builder, ciborium, regex-automata, cpex-core, serde…\r"] +[0.005, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 373/404: ciborium-ll, plotters, clap_builder, ciborium, regex-automata, cpex-core, serde…\r"] +[0.001, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 374/404: plotters, clap_builder, ciborium, regex-automata, cpex-core, serde_yaml, nu-ans…\r"] +[0.045, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 375/404: plotters, clap_builder, ciborium, regex-automata, cpex-core, nu-ansi-term, wasm…\r"] +[0.015, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 376/404: plotters, clap_builder, ciborium, regex-automata, cpex-core, nu-ansi-term, wasm…\r"] +[0.018, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 377/404: plotters, clap_builder, ciborium, regex-automata, cpex-core, nu-ansi-term, wasm…\r"] +[0.033, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tracing-subscriber v0.3.23\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 377/404: tracing-subscriber, plotters, clap_builder, ciborium, regex-automata, cpex-core…\r"] +[0.008, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 378/404: tracing-subscriber, plotters, clap_builder, ciborium, regex-automata, cpex-core…\r"] +[0.025, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 379/404: tracing-subscriber, plotters, clap_builder, ciborium, regex-automata, cpex-core…\r"] +[0.015, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 380/404: tracing-subscriber, plotters, clap_builder, ciborium, cpex-core, rustls, wasmti…"] +[0.000, "o", "\r"] +[0.019, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 381/404: tracing-subscriber, plotters, clap_builder, ciborium, cpex-core, rustls, wasmti…\r"] +[0.030, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 382/404: tracing-subscriber, plotters, ciborium, cpex-core, rustls, wasmtime-environ, cr…\r"] +[0.003, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 383/404: tracing-subscriber, plotters, cpex-core, rustls, wasmtime-environ, cranelift-co…\r"] +[0.028, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m criterion v0.5.1\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 383/404: tracing-subscriber, plotters, cpex-core, criterion, rustls, wasmtime-environ, c…\r"] +[0.045, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 384/404: tracing-subscriber, cpex-core, criterion, rustls, wasmtime-environ, cranelift-c…\r"] +[0.277, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m tokio-rustls v0.26.4\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 384/404: tracing-subscriber, cpex-core, criterion, rustls, wasmtime-environ, cranelift-c…\r"] +[0.043, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 384/404: tracing-subscriber, wasmtime-internal-cache, cpex-core, criterion, rustls, wasm…\r"] +[0.061, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 385/404: tracing-subscriber, wasmtime-internal-cache, cpex-core, criterion, rustls, wasm…\r"] +[0.076, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 386/404: tracing-subscriber, wasmtime-internal-cache, cpex-core, criterion, rustls, wasm…\r"] +[0.045, "o", "\u001b[1m\u001b[96m Building\u001b[0m [======================> ] 387/404: wasmtime-internal-cache, cpex-core, criterion, rustls, wasmtime-environ, cranel…\r"] +[0.254, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 388/404: cpex-core, criterion, rustls, wasmtime-environ, cranelift-codegen \r"] +[0.017, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 389/404: cpex-core, criterion, wasmtime-environ, cranelift-codegen \r"] +[0.219, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 390/404: cpex-core, criterion, cranelift-codegen \r"] +[0.203, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 391/404: cpex-core, cranelift-codegen \r"] +[0.572, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 392/404: cranelift-codegen \r"] +[1.823, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-frontend v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m cranelift-native v0.132.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 392/404: cranelift-native, wasmtime-internal-unwinder, cranelift-codegen, cranelift-fron…\r"] +[0.065, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 393/404: wasmtime-internal-unwinder, cranelift-codegen, cranelift-frontend \r"] +[0.071, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 394/404: cranelift-codegen, cranelift-frontend \r"] +[0.026, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 394/404: wasmtime-internal-cranelift, cranelift-codegen, cranelift-frontend \r"] +[0.096, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 395/404: wasmtime-internal-cranelift, cranelift-codegen \r"] +[0.898, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 395/404: wasmtime-internal-cranelift, cranelift-codegen, wasmtime \r"] +[0.476, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 396/404: wasmtime-internal-cranelift, wasmtime \r"] +[0.287, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 397/404: wasmtime \r"] +[3.472, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-wasi-io v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m wiggle v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 397/404: wiggle, wasmtime-wasi-io, wasmtime \r"] +[0.277, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 398/404: wasmtime-wasi-io, wasmtime \r"] +[0.074, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-wasi v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 398/404: wasmtime-wasi-io, wasmtime-wasi, wasmtime \r"] +[0.038, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 399/404: wasmtime-wasi, wasmtime \r"] +[1.635, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 400/404: wasmtime-wasi \r"] +[2.695, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m wasmtime-wasi-http v45.0.3\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m "] +[0.000, "o", "[=======================> ] 400/404: wasmtime-wasi-http, wasmtime-wasi \r"] +[1.021, "o", "\u001b[K\u001b[1m\u001b[92m Compiling\u001b[0m cpex-wasm-host v0.2.2 (/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/crates/cpex-wasm-host)\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 400/404: wasmtime-wasi-http, wasmtime-wasi, cpex-wasm-host \r"] +[0.066, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 401/404: wasmtime-wasi-http, cpex-wasm-host \r"] +[0.237, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 402/404: cpex-wasm-host \r"] +[5.470, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 403/404: wasm_plugin_demo(example) \r"] +[0.827, "o", "\u001b[K\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 33.04s\r\n"] +[0.008, "o", "\u001b[1m\u001b[92m Running\u001b[0m `target/debug/examples/wasm_plugin_demo`\r\n"] +[0.913, "o", "=== WASM Plugin Demo ===\r\n\r\n"] +[0.000, "o", "--- Loading config from config/config_plugin_demo.yaml ---\r\n\r\n"] +[3.508, "o", "\u001b[2m2026-08-07T01:56:46.545651Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Registered plugin 'identity-resolver' (kind: 'wasm://tool-invoke-checker.wasm') for hooks: [\"tool_pre_invoke\", \"tool_post_invoke\"]\r\n"] +[3.710, "o", "\u001b[2m2026-08-07T01:56:50.255956Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Registered plugin 'pii-guard' (kind: 'wasm://pii-guard.wasm') for hooks: [\"tool_pre_invoke\"]\r\n"] +[3.656, "o", "\u001b[2m2026-08-07T01:56:53.912021Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Registered plugin 'remote-authz' (kind: 'wasm://remote-authz.wasm') for hooks: [\"tool_pre_invoke\"]\r\n"] +[3.686, "o", "\u001b[2m2026-08-07T01:56:57.597852Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Registered plugin 'audit-logger' (kind: 'wasm://audit-logger-custom.wasm') for hooks: [\"tool_pre_invoke\", \"tool_post_invoke\"]\r\n\r\n--- Initializing plugins ---\r\n\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T01:56:57.597913Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Initializing PluginManager with 4 plugins\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T01:56:57.597959Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m PluginManager initialized successfully\r\n\r\nPlugins loaded: 4\r\n"] +[0.000, "o", "Hooks registered: tool_pre_invoke=true, tool_post_invoke=true\r\n\r\n"] +[2.002, "o", "\u001b[1;36m\r\n=== Scenario 1: get_compensation (PII tool, no clearance) ===\r\n\u001b[0m\r\n"] +[0.005, "o", "\u001b[2m2026-08-07T01:56:59.605102Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [identity-resolver] OK: user 'alice' identified \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0midentity-resolver\r\n"] +[0.002, "o", "\u001b[2m2026-08-07T01:56:59.606874Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [pii-guard] DENIED: user 'alice' lacks PII clearance for 'get_compensation' \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mpii-guard\r\n"] +[0.000, "o", " \u001b[31mResult: DENIED by 'pii-guard' — PII clearance required [pii_access_denied]\u001b[0m\r\n"] +[4.004, "o", "\u001b[1;36m\r\n=== Scenario 2: get_compensation (PII tool, with clearance) ===\r\n\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:03.612318Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [identity-resolver] OK: user 'alice' identified \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0midentity-resolver\r\n"] +[0.002, "o", "\u001b[2m2026-08-07T01:57:03.613594Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [pii-guard] OK: user 'alice' has PII clearance \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mpii-guard\r\n"] +[0.000, "o", " \u001b[97mResult: ALLOWED\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:03.615487Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [audit-logger] LOG: user='alice' tool='get_compensation' args='employee_id=42' \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0maudit-logger\r\n"] +[0.001, "o", "\r\n --- post-invoke for get_compensation ---\r\n\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T01:57:03.616501Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [identity-resolver] OK: user 'alice' identified \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0midentity-resolver\r\n"] +[0.001, "o", " \u001b[97mResult: ALLOWED\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T01:57:03.617126Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [audit-logger] LOG: user='alice' tool='get_compensation' args='employee_id=42' \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0maudit-logger\r\n"] +[4.003, "o", "\u001b[1;36m\r\n=== Scenario 3: list_departments (non-PII tool) ===\r\n\u001b[0m\r\n"] +[0.002, "o", "\u001b[2m2026-08-07T01:57:07.621688Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [identity-resolver] OK: user 'bob' identified \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0midentity-resolver\r\n"] +[0.000, "o", " \u001b[97mResult: ALLOWED\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:07.622775Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [audit-logger] LOG: user='bob' tool='list_departments' args='' \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0maudit-logger\r\n"] +[4.004, "o", "\u001b[1;36m\r\n=== Scenario 4: some_other_tool (wildcard route) ===\r\n\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:11.628106Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [identity-resolver] OK: user 'charlie' identified \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0midentity-resolver\r\n"] +[0.000, "o", " \u001b[97mResult: ALLOWED\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:11.629224Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [audit-logger] LOG: user='charlie' tool='some_other_tool' args='foo=bar' \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0maudit-logger\r\n"] +[4.005, "o", "\u001b[1;36m\r\n=== Scenario 5: query_external_data (remote authz, ACL hit) ===\r\n\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:15.635238Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [identity-resolver] OK: user 'alice' identified \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0midentity-resolver\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:15.636365Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [remote-authz] initializing ACL (first invocation — simulating remote fetch) \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mremote-authz\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T01:57:15.636595Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [remote-authz] ACL cached (2 users) \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mremote-authz\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T01:57:15.636773Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [remote-authz] OK (ACL hit): user 'alice' allowed \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mremote-authz\r\n"] +[0.001, "o", " \u001b[97mResult: ALLOWED\u001b[0m\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T01:57:15.637533Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [audit-logger] LOG: user='alice' tool='query_external_data' args='dataset=sales' \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0maudit-logger\r\n"] +[4.004, "o", "\u001b[1;36m\r\n=== Scenario 6: query_external_data (remote authz, ACL miss) ===\r\n\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:19.642323Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [identity-resolver] OK: user 'charlie' identified \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0midentity-resolver\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:19.643330Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [remote-authz] DENIED (ACL miss): user 'charlie' not in remote ACL \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0mremote-authz\r\n"] +[0.000, "o", " \u001b[31mResult: DENIED by 'remote-authz' — User 'charlie' not in remote ACL [remote_authz_denied]\u001b[0m\r\n"] +[4.004, "o", "\u001b[1;36m\r\n=== Scenario 7: list_departments (no user identity) ===\r\n"] +[0.000, "o", "\u001b[0m\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:23.648390Z\u001b[0m \u001b[33m WARN\u001b[0m \u001b[2mcpex_wasm_host::sandbox_manager\u001b[0m\u001b[2m:\u001b[0m [identity-resolver] DENIED: no user identity \u001b[3mplugin\u001b[0m\u001b[2m=\u001b[0midentity-resolver\r\n"] +[0.001, "o", " \u001b[31mResult: DENIED by 'identity-resolver' — User identity is required [no_identity]\u001b[0m\r\n"] +[2.002, "o", "--- Shutting down ---\r\n\r\n"] +[0.000, "o", "\u001b[2m2026-08-07T01:57:25.651767Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m Shutting down PluginManager\r\n"] +[0.001, "o", "\u001b[2m2026-08-07T01:57:25.651979Z\u001b[0m \u001b[32m INFO\u001b[0m \u001b[2mcpex_core::manager\u001b[0m\u001b[2m:\u001b[0m PluginManager shutdown complete\r\n=== Demo complete ===\r\n"] +[0.032, "x", "0"] diff --git a/docs/content/docs/wasm/recordings/resource-limit-demo.cast b/docs/content/docs/wasm/recordings/resource-limit-demo.cast new file mode 100644 index 00000000..5b0c4bed --- /dev/null +++ b/docs/content/docs/wasm/recordings/resource-limit-demo.cast @@ -0,0 +1,61 @@ +{"version":3,"term":{"cols":146,"rows":41,"type":"xterm-256color","theme":{"fg":"#28fe14","bg":"#000000","palette":"#000000:#990000:#00a600:#999900:#0000b3:#b300b3:#00a6b3:#bfbfbf:#666666:#e60000:#00d900:#e6e600:#0000ff:#e600e6:#00e6e6:#e6e6e6"}},"timestamp":1786120191,"command":"cargo test -p cpex-wasm-host test_sandbox_resource_limits","env":{"SHELL":"/bin/zsh"}} +[0.225, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m cpex-wasm-host v0.2.2 (/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/crates/cpex-wasm-host)\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 403/414: cpex_wasm_host(test), test_execution_modes(test), test_security_enforcement(test), test_sandb…\r"] +[0.708, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 404/414: cpex_wasm_host(test), test_execution_modes(test), test_security_enforcement(test), test_sandb…\r"] +[0.025, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 405/414: cpex_wasm_host(test), test_execution_modes(test), test_sandbox_resource_limits(test), test_cu…\r"] +[0.695, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 406/414: cpex_wasm_host(test), test_execution_modes(test), test_sandbox_resource_limits(test), test_cu…\r"] +[0.096, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 407/414: cpex_wasm_host(test), test_execution_modes(test), test_sandbox_resource_limits(test), test_cu…\r"] +[0.068, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 408/414: cpex_wasm_host(test), test_execution_modes(test), test_custom_payload_pipeline(test), test_to…\r"] +[0.004, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 409/414: cpex_wasm_host(test), test_execution_modes(test), test_custom_payload_pipeline(test), test_to…\r"] +[0.123, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 410/414: cpex_wasm_host(test), test_custom_payload_pipeline(test), test_token_delegation_e2e(test), te…\r"] +[0.043, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 411/414: cpex_wasm_host(test), test_custom_payload_pipeline(test), test_token_delegation_e2e(test) \r"] +[0.036, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 412/414: cpex_wasm_host(test), test_custom_payload_pipeline(test) \r"] +[0.027, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 413/414: cpex_wasm_host(test) \r"] +[1.148, "o", "\u001b[K\u001b[1m\u001b[92m Finished\u001b[0m `test` profile [unoptimized + debuginfo] target(s) in 3.12s\r\n"] +[0.010, "o", "\u001b[1m\u001b[92m Running\u001b[0m unittests src/lib.rs (target/debug/deps/cpex_wasm_host-7f0ba19a4220c82e)\r\n"] +[0.364, "o", "\r\nrunning 0 tests\r\n\r\ntest result: "] +[0.000, "o", "\u001b[32mok\u001b(B\u001b[m. 0 passed; 0 failed; 0 ignored; 0 measured; 43 filtered out; finished in 0.00s"] +[0.000, "o", "\r\n\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_custom_payload_pipeline.rs (target/debug/deps/test_custom_payload_pipeline-434417e58310362c)\r\n"] +[0.855, "o", "\r\nrunning 0 tests\r\n\r\ntest result: \u001b[32mok\u001b(B\u001b[m"] +[0.000, "o", ". 0 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out"] +[0.000, "o", "; finished in 0.00s\r\n\r\n"] +[0.001, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_execution_modes.rs (target/debug/deps/test_execution_modes-3c1a3ddf1e797593)\r\n"] +[0.876, "o", "\r\nrunning 0 tests\r\n\r\ntest result: \u001b[32mok\u001b(B\u001b[m. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out"] +[0.000, "o", "; finished in 0.00s\r\n\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_identity_resolve_e2e.rs (target/debug/deps/test_identity_resolve_e2e-892dd4136e8e53fc)\r\n"] +[0.837, "o", "\r\nrunning 0 tests\r\n\r\ntest result: \u001b[32mok\u001b(B\u001b[m"] +[0.000, "o", ". 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out"] +[0.000, "o", "; finished in 0.00s\r\n\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_policy_loader.rs (target/debug/deps/test_policy_loader-86bd17e6260bcb20)\r\n"] +[0.184, "o", "\r\nrunning 0 tests\r\n\r\ntest result: "] +[0.000, "o", "\u001b[32mok\u001b(B\u001b[m"] +[0.000, "o", ". 0 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out"] +[0.000, "o", "; finished in 0.00s\r\n\r\n"] +[0.001, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_sandbox_env.rs (target/debug/deps/test_sandbox_env-ff183bd22c98dd5c)\r\n"] +[0.794, "o", "\r\nrunning 0 tests\r\n\r\ntest result: "] +[0.000, "o", "\u001b[32mok\u001b(B\u001b[m. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out"] +[0.000, "o", "; finished in 0.00s\r\n\r\n"] +[0.001, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_sandbox_isolation.rs (target/debug/deps/test_sandbox_isolation-255c5ed61ac9911f)\r\n"] +[0.838, "o", "\r\nrunning 0 tests\r\n\r\ntest result: \u001b[32mok\u001b(B\u001b[m. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out"] +[0.000, "o", "; finished in 0.00s\r\n\r\n"] +[0.001, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_sandbox_network.rs (target/debug/deps/test_sandbox_network-daf395928324f916)\r\n"] +[0.840, "o", "\r\nrunning 0 tests\r\n\r\ntest result: "] +[0.000, "o", "\u001b[32mok\u001b(B\u001b[m"] +[0.000, "o", ". 0 passed; 0 failed; 0 ignored; 0 measured; 7 filtered out"] +[0.000, "o", "; finished in 0.00s"] +[0.000, "o", "\r\n\r\n"] +[0.001, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_sandbox_resource_limits.rs (target/debug/deps/test_sandbox_resource_limits-46348e9ac5ac2833)\r\n"] +[0.829, "o", "\r\nrunning 0 tests\r\n\r\ntest result: \u001b[32mok\u001b(B\u001b[m"] +[0.000, "o", ". 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out"] +[0.000, "o", "; finished in 0.00s\r\n\r\n"] +[0.001, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_security_enforcement.rs (target/debug/deps/test_security_enforcement-eaa40d3ac0890443)\r\n"] +[0.212, "o", "\r\nrunning 0 tests\r\n\r\ntest result: "] +[0.000, "o", "\u001b[32mok\u001b(B\u001b[m"] +[0.000, "o", ". 0 passed; 0 failed; 0 ignored; 0 measured; 15 filtered out"] +[0.000, "o", "; finished in 0.00s\r\n\r\n"] +[0.000, "o", "\u001b[1m\u001b[92m Running\u001b[0m tests/test_token_delegation_e2e.rs (target/debug/deps/test_token_delegation_e2e-0392d06e61b79df4)\r\n"] +[0.828, "o", "\r\nrunning 0 tests\r\n\r\ntest result: \u001b[32mok\u001b(B\u001b[m"] +[0.000, "o", ". 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out"] +[0.000, "o", "; finished in 0.00s\r\n\r\n"] +[0.005, "x", "0"] diff --git a/docs/content/docs/wasm/recordings/resource-limits-demo.cast b/docs/content/docs/wasm/recordings/resource-limits-demo.cast new file mode 100644 index 00000000..0eda482d --- /dev/null +++ b/docs/content/docs/wasm/recordings/resource-limits-demo.cast @@ -0,0 +1,14 @@ +{"version":3,"term":{"cols":146,"rows":41,"type":"xterm-256color","theme":{"fg":"#28fe14","bg":"#000000","palette":"#000000:#990000:#00a600:#999900:#0000b3:#b300b3:#00a6b3:#bfbfbf:#666666:#e60000:#00d900:#e6e600:#0000ff:#e600e6:#00e6e6:#e6e6e6"}},"timestamp":1786120559,"command":"cd crates/cpex-wasm-host && cargo run --example wasm_resource_limits_demo","env":{"SHELL":"/bin/zsh"}} +[0.213, "o", "\u001b[1m\u001b[92m Compiling\u001b[0m cpex-wasm-host v0.2.2 (/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/crates/cpex-wasm-host)\r\n"] +[0.000, "o", "\u001b[1m\u001b[96m Building\u001b[0m [=======================> ] 403/404: wasm_resource_limits_demo(example) \r"] +[0.779, "o", "\u001b[K\u001b[1m\u001b[92m Finished\u001b[0m `dev` profile [unoptimized + debuginfo] target(s) in 0.92s\r\n"] +[0.009, "o", "\u001b[1m\u001b[92m Running\u001b[0m `/Users/shritipriya/Documents/WASM-PLUGIN-CRATES-JUL28/feat-plugine2e-original/contextforge-plugins-framework/target/debug/examples/wasm_resource_limits_demo`\r\n"] +[0.851, "o", "\u001b[1m=== Resource Limits Sandbox Demo ===\u001b[0m\r\n\r\n\u001b[2mPlugin:\u001b[0m resource-test.wasm\r\n"] +[0.000, "o", "\u001b[2mPayload:\u001b[0m mode passed as ToolCall argument\r\n\u001b[2mGoal:\u001b[0m each scenario triggers a resource limit trap, proving the host\r\n\u001b[2m \u001b[0mcleanly terminates runaway plugins without affecting itself.\r\n\r\n"] +[0.010, "o", "\u001b[1;36mScenario 1: fuel exhaustion (max_fuel=10,000)\u001b[0m\r\n"] +[3.572, "o", " \u001b[31m[TRAPPED]\u001b[0m mode=burn_fuel limit=max_fuel=10,000\r\n \u001b[31m→ Plugin trapped in 1.098833ms\u001b[0m\r\n \u001b[2mError: plugin invocation failed: error while executing at wasm backtrace:\r\n 0: 0x2b71 - cpex_wasm_plugin.wasm!cpex_wasm_plugin::__block_on::h5fc50faf6cbfaaaa\r\n 1: 0x190c4 - cpex_wasm_plugin.wasm!::handle_hook::h108184478d55bcc7\r\n 2: 0xba24 - cpex_wasm_plugin.wasm!cpex_wasm_plugin::_export_handle_hook_cabi::h3e7704266ca1bdb4\r\n 3: 0x1de09 - cpex_wasm_plugin.wasm!handle-hook\u001b[0m\r\n\r\n"] +[0.000, "o", "\u001b[1;36mScenario 2: epoch timeout (max_execution_time_ms=200)\u001b[0m\r\n"] +[3.676, "o", " \u001b[31m[TRAPPED]\u001b[0m mode=burn_fuel limit=max_execution_time_ms=200\r\n \u001b[31m→ Plugin trapped in 56.668375ms\u001b[0m\r\n \u001b[2mError: plugin invocation failed: error while executing at wasm backtrace:\r\n 0: 0x2b71 - cpex_wasm_plugin.wasm!cpex_wasm_plugin::__block_on::h5fc50faf6cbfaaaa\r\n 1: 0x190c4 - cpex_wasm_plugin.wasm!::handle_hook::h108184478d55bcc7\r\n 2: 0xba24 - cpex_wasm_plugin.wasm!cpex_wasm_plugin::_export_handle_hook_cabi::h3e7704266ca1bdb4\r\n 3: 0x1de09 - cpex_wasm_plugin.wasm!handle-hook\u001b[0m\r\n\r\n\u001b[1;36mScenario 3: memory limit (max_memory_bytes=5MB)\u001b[0m\r\n"] +[3.346, "o", " \u001b[31m[TRAPPED]\u001b[0m mode=alloc_memory limit=max_memory_bytes=5MB\r\n \u001b[31m→ Plugin trapped in 516.875µs\u001b[0m\r\n \u001b[2mError: plugin invocation failed: error while executing at wasm backtrace:\r\n 0: 0xa9105 - cpex_wasm_plugin.wasm!sbrk\r\n 1: 0xa9be3 - cpex_wasm_plugin.wasm!dlmalloc\r\n 2: 0xab39b - cpex_wasm_plugin.wasm!calloc\r\n 3: 0xa482d - cpex_wasm_plugin.wasm!__rustc[8068f81614cfe5c]::__rdl_alloc_zeroed\r\n 4: 0x67242 - cpex_wasm_plugin.wasm!__rustc[8068f81614cfe5c]::__rust_alloc_zeroed\r\n 5: 0x2c44 - cpex_wasm_plugin.wasm!cpex_wasm_plugin::__block_on::h5fc50faf6cbfaaaa\r\n 6: 0x190c4 - cpex_wasm_plugin.wasm!::handle_hook::h108184478d55bcc7\r\n 7: 0xba24 - cpex_wasm_plugin.wasm!cpex_wasm_plugin::_export_handle_hook_cabi::h3e7704266ca1bdb4\r\n 8: 0x1de09 - cpex_wasm_plugin.wasm!handle-hook\u001b[0m\r\n\r\n"] +[0.000, "o", "\u001b[1m=== Demo complete ===\u001b[0m\r\n\r\n\u001b[2mAll three resource limits fired correctly — runaway plugins are\r\nterminated cleanly without host degradation.\u001b[0m\r\n\r\n"] +[0.011, "x", "0"] diff --git a/docs/content/docs/wasm/sandboxing-details.md b/docs/content/docs/wasm/sandboxing-details.md new file mode 100644 index 00000000..ccdc7ded --- /dev/null +++ b/docs/content/docs/wasm/sandboxing-details.md @@ -0,0 +1,256 @@ +--- +title: "Sandboxing Details" +weight: 50 +--- + +# Sandboxing Details + +Every WASM plugin runs inside a Wasmtime sandbox with **deny-by-default** policies. This page documents all sandboxing dimensions, permission levels, and agentic scenarios where each applies. + +## Overview of sandbox layers + +| Layer | What it controls | Default | +|-------|-----------------|---------| +| Filesystem | Directory/file preopens with permission levels | No access | +| Environment | Which env vars are visible inside the sandbox | None visible | +| Network | Outbound HTTP filtered by host/port/scheme/method | All blocked | +| Resources | CPU fuel, wall-clock timeout, memory ceiling | Engine defaults | + +All layers compose — a plugin can have filesystem access but no network, or network access but no filesystem. Each is configured independently in the `sandbox` block of the plugin's YAML config. + +--- + +## Filesystem permissions + +Six permission levels control what a plugin can do with preopened directories: + +### 1. `read-only` + +**Grants:** list directory, read file contents +**Denies:** write, create, delete + +```yaml +filesystem: + - dir: "./data/rules" + permission: read-only +``` + +**Agentic scenario:** A policy-evaluation plugin that reads rule definitions at startup. The plugin can inspect rule files but cannot modify them, preventing a compromised plugin from altering its own governance rules. + +### 2. `full-access` + +**Grants:** list, read, write, create, delete — all operations +**Denies:** nothing within the preopened path + +```yaml +filesystem: + - dir: "./data/cache" + permission: full-access +``` + +**Agentic scenario:** A caching plugin that stores computed results. The agent orchestrator grants full access to a cache directory so the plugin can create, update, and evict cache entries autonomously. + +### 3. `drop-box` + +**Grants:** write (create new files, append) +**Denies:** list directory, read file contents + +```yaml +filesystem: + - dir: "./data/audit" + permission: drop-box +``` + +**Agentic scenario:** An audit-logging plugin that writes compliance records. The plugin can emit audit entries but cannot read back previous logs, preventing data exfiltration through the audit channel. Even if compromised, it cannot enumerate what other plugins have logged. + +### 4. `fixed-mutable` + +**Grants:** read existing files, overwrite existing files +**Denies:** create new files, create directories, delete + +```yaml +filesystem: + - dir: "./data/counters" + permission: fixed-mutable +``` + +**Agentic scenario:** A rate-limiting plugin that maintains counters in pre-created files. The plugin can read and update counter values but cannot create arbitrary new files, bounding its filesystem footprint to a known set. + +### 5. `list-only` + +**Grants:** enumerate filenames in the directory +**Denies:** read file contents, write, create, delete + +```yaml +filesystem: + - dir: "./data/plugins" + permission: list-only +``` + +**Agentic scenario:** A plugin-discovery agent that scans available plugins by filename convention. It can see what plugins exist without reading their code or config, limiting information exposure to structural metadata only. + +### 6. `private-scratch` + +**Grants:** write new files, read own files +**Denies:** list directory (cannot enumerate other files) + +```yaml +filesystem: + - dir: "./data/scratch" + permission: private-scratch +``` + +**Agentic scenario:** A multi-tenant plugin where each invocation writes temporary working files. The plugin can create and read back its own files but cannot discover files left by other invocations or plugins sharing the same scratch space. + +### Permission summary table + +| Permission | list | read | write | create | delete | +|-----------|------|------|-------|--------|--------| +| read-only | yes | yes | no | no | no | +| full-access | yes | yes | yes | yes | yes | +| drop-box | no | no | yes | yes | no | +| fixed-mutable | yes | yes | yes | no | no | +| list-only | yes | no | no | no | no | +| private-scratch | no | yes | yes | yes | no | + +--- + +## Environment variable permissions + +By default, **no environment variables** are visible inside the WASM sandbox. The host's `build_wasi_context` only injects variables explicitly listed in `allowed_env` — it never calls `inherit_env()`. + +### Configuration + +```yaml +sandbox: + env_vars: + - APP_TOKEN + - LOG_LEVEL + - DEPLOYMENT_ENV +``` + +### What gets blocked + +Any variable NOT in the `allowed_env` list is invisible to the guest. Common variables hidden by default: + +| Variable | Why it's hidden | +|----------|----------------| +| `HOME` | Reveals host filesystem structure | +| `PATH` | Exposes installed tooling | +| `SECRET_API_KEY` | Credential leakage | +| `AWS_SECRET_ACCESS_KEY` | Cloud credential leakage | +| `DATABASE_URL` | Infrastructure topology | + +### Agentic scenario + +An agent orchestrator runs multiple third-party plugins. Each plugin needs only its own API token (`PLUGIN_X_TOKEN`) and a log level. By allowlisting only those two variables, a compromised plugin cannot harvest credentials for other services, even if the host process has them in its environment. + +--- + +## Network permissions + +Network access is controlled via WASI HTTP (`wasi:http/outgoing-handler`). Raw TCP/UDP sockets are not available in WASI P2. + +### Default: deny-all + +With no `network` config, all outbound HTTP requests are blocked by the `NetworkPolicy` implementation of `WasiHttpHooks`. + +### Configuration + +```yaml +sandbox: + network: + - host: "api.example.com" + ports: [443] + schemes: ["https"] + methods: ["GET", "POST"] + + - host: "*.internal.corp" + ports: [] # empty = any port + schemes: ["https"] + methods: [] # empty = any method + + - host: "telemetry.vendor.io" + ports: [443] + schemes: ["https"] + methods: ["POST"] +``` + +### Rule fields + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `host` | yes | — | Exact match or wildcard (`*.example.com`) | +| `ports` | no | any | Allowed port numbers | +| `schemes` | no | `["https"]` | Allowed URL schemes | +| `methods` | no | any | Allowed HTTP methods | + +### Enforcement behavior + +The `NetworkPolicy` checks each outbound request against all rules. A request is allowed if **any** rule matches on all four dimensions (host AND port AND scheme AND method). If no rule matches, the request is denied and the plugin receives an error. + +### Wildcard matching + +`*.example.com` matches `api.example.com` and `staging.api.example.com` but NOT `example.com` itself. + +### Agentic scenarios + +**Remote authorization:** A plugin calls an external PDP (Policy Decision Point) to authorize tool invocations. Network rules restrict it to exactly one host on HTTPS port 443 with POST only — preventing it from exfiltrating data to other endpoints. + +**Telemetry only:** A metrics plugin can POST to a telemetry endpoint but cannot make GET requests (which might fetch attacker-controlled instructions). + +**Internal services:** Wildcard `*.internal.corp` allows the plugin to reach any internal microservice while blocking all external traffic. + +--- + +## Resource permissions + +Resource limits prevent plugins from consuming unbounded CPU, memory, or wall-clock time. + +### Configuration + +```yaml +sandbox: + resources: + max_fuel: 500_000_000 # Instruction budget + max_execution_time_ms: 5000 # Wall-clock timeout + max_memory_bytes: 10_485_760 # 10 MB memory ceiling + max_instances: 10 # Component instances + max_tables: 10 # Table elements +``` + +### Fuel (CPU budget) + +Fuel is Wasmtime's instruction counter. Each WASM instruction consumes one unit of fuel. When fuel runs out, execution traps immediately. + +| Fuel budget | Approximate workload | +|-------------|---------------------| +| 100,000,000 | Simple validation (string checks, JSON field access) | +| 500,000,000 | Moderate computation (parsing, hashing, policy evaluation) | +| 1,000,000,000 | Heavy computation (cryptographic operations, complex transforms) | + +**Agentic scenario:** A validation plugin should finish in microseconds. Setting fuel to 100M ensures a bug (infinite loop, accidental recursion) traps before consuming noticeable CPU, protecting the host's throughput. + +### Epoch timeout (wall-clock) + +The shared engine's epoch ticker increments every 1ms. Each `Store` has a deadline set before invocation. If the epoch exceeds the deadline, execution traps. + +This catches scenarios fuel alone cannot: blocking I/O, sleep-like patterns, and WASI calls that don't consume fuel. + +**Agentic scenario:** A plugin making an outbound HTTP call might hang if the remote server is unresponsive. The epoch timeout (e.g., 5000ms) ensures the plugin is killed regardless of whether it's burning fuel or waiting on I/O. + +### Memory ceiling + +`max_memory_bytes` sets the upper bound on the plugin's linear memory growth. Attempting to grow past this limit traps the plugin. + +**Agentic scenario:** A plugin processing user input could be tricked into allocating unbounded memory (zip bomb, recursive JSON). The memory ceiling prevents a single plugin from exhausting host memory. + +### What happens on limit violation + +| Limit | Error variant | Behavior | +|-------|--------------|----------| +| Fuel exhausted | `PluginError::FuelExhausted` | Immediate trap, store dropped | +| Epoch timeout | `PluginError::Timeout` | Immediate trap, store dropped | +| Memory exceeded | `PluginError::MemoryLimit` | Trap on `memory.grow`, store dropped | + +In all cases, the `Store` (and all plugin memory) is dropped after the trap. The next invocation starts fresh. diff --git a/docs/content/docs/wasm/tutorials/_index.md b/docs/content/docs/wasm/tutorials/_index.md new file mode 100644 index 00000000..47cf5e2f --- /dev/null +++ b/docs/content/docs/wasm/tutorials/_index.md @@ -0,0 +1,31 @@ +--- +title: "Tutorials" +weight: 70 +bookCollapseSection: false +--- + +# Tutorials + +Hands-on walkthroughs using the built-in demos. Each tutorial runs real WASM plugins end-to-end. + +## Prerequisites + +```bash +rustup target add wasm32-wasip2 +cd crates/cpex-wasm-host +``` + +## What's in this section + +- [Part 1: Plugin Demos]({{< relref "part1" >}}) — custom payloads, policy-based routing, and capability filtering +- [Part 2: Sandboxing Demos]({{< relref "part2" >}}) — filesystem, env, network, and resource limit enforcement + +--- + +## Running all demos at once + +```bash +make run-all-demos +``` + +This builds all plugins and runs every demo sequentially, producing a full output log showing each sandboxing dimension in action. diff --git a/docs/content/docs/wasm/tutorials/part1.md b/docs/content/docs/wasm/tutorials/part1.md new file mode 100644 index 00000000..2b457bd7 --- /dev/null +++ b/docs/content/docs/wasm/tutorials/part1.md @@ -0,0 +1,117 @@ +--- +title: "Part 1: Plugin Demos" +weight: 10 +--- + +# Part 1: Using WASM Plugins with CPEX + +## Tutorial 1: Plugin Demo (custom payload pipeline) + +**What you'll learn:** How to define a custom payload type, register multiple plugins with policy-based routing, and invoke them through the pipeline. + +{{< asciinema cast="https://asciinema.org/a/neCYIg5MN6V2aoCY.cast" poster="npt:0:03" >}} + + + +**Run it:** + +```bash +make run-plugin-demo +``` + +**What happens:** + +1. A custom `ToolInvokePayload` is defined with `tool_name`, `user`, and `arguments` fields +2. Four plugins are loaded from `config/config_plugin_demo.yaml`: + - **identity-resolver** (priority 10) — resolves caller identity on every request + - **pii-guard** (priority 20) — blocks requests containing PII (activated by "pii" tag) + - **remote-authz** (priority 30) — calls external PDP for authorization (activated by "needs_remote_authz" tag) + - **audit-logger** (priority 100) — logs all invocations in fire-and-forget mode +3. Seven scenarios are executed with different route tags triggering different plugin combinations + +**Key code pattern:** + +```rust +// Define a custom payload +#[derive(Clone, Debug, Serialize, Deserialize)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: HashMap, +} +impl_plugin_payload!(ToolInvokePayload, "tool_invoke"); +impl_wasm_payload!(ToolInvokePayload, "tool_invoke"); + +// Register it +let mut registry = PayloadSerializerRegistry::new(); +registry.register::(); + +// Create factory with custom registry +let factory = WasmPluginFactory::new("./wasm", Arc::new(registry))?; +``` + +**Config excerpt** (`config/config_plugin_demo.yaml`): + +```yaml +policies: + pii: + condition: { tag: "pii" } + plugins: [pii-guard] + external_authz: + condition: { tag: "needs_remote_authz" } + plugins: [remote-authz] + +routes: + - name: get_compensation + tags: [pii, hr] + - name: query_external_data + tags: [needs_remote_authz] +``` + +--- + +## Tutorial 2: Capabilities Demo (extension filtering) + +**What you'll learn:** How capability declarations control which extension fields a plugin can read and write across the WASM boundary. + +{{< asciinema cast="https://asciinema.org/a/utOxkW2ZY1FGbwtk.cast" poster="npt:0:03" >}} + +**Run it:** + +```bash +make run-capabilities-demo +``` + +**What happens:** + +1. Three plugins are loaded with different capability sets: + - **identity-checker**: `[read_labels, read_subject, read_roles]` + - **header-injector**: `[read_headers, write_headers, append_labels]` + - **audit-logger**: `[read_headers, read_labels]` (audit mode) +2. Extensions are built with `SecurityExtension`, `HttpExtension`, `MetaExtension` +3. Each plugin only sees the extension fields matching its capabilities +4. After invocation, `modified_extensions` reflects only authorized changes + +**Key concept — capability filtering:** + +```yaml +# header-injector can read and write headers, and append labels +capabilities: + - read_headers + - write_headers + - append_labels +``` + +The `WasmBridgeHandler`: +- Before the call: zeros extension fields the plugin has no `read_*` capability for +- After the call: discards modifications to fields the plugin has no `write_*` capability for +- This happens at the host level — the guest cannot bypass it + +**Observe the output:** + +``` +identity-checker sees subject: "user:alice" ✓ +identity-checker tries to write headers → discarded (no write_headers capability) +header-injector sees headers ✓, adds x-plugin-trace header ✓ +header-injector does NOT see subject (no read_subject capability) +``` diff --git a/docs/content/docs/wasm/tutorials/part2.md b/docs/content/docs/wasm/tutorials/part2.md new file mode 100644 index 00000000..0c5ffbe0 --- /dev/null +++ b/docs/content/docs/wasm/tutorials/part2.md @@ -0,0 +1,213 @@ +--- +title: "Part 2: Sandboxing Demos" +weight: 20 +--- + +# Part 2: Sandboxing Tutorials + +## Tutorial 3: Filesystem Permissions + +**What you'll learn:** How the six filesystem permission levels enforce access control inside the WASM sandbox. + +{{< asciinema cast="https://asciinema.org/a/pigLCqNdRIm85GID.cast" poster="npt:0:03" >}} + +**Run it:** + +```bash +make run-sandbox-demo +``` + +**What happens:** + +The `fs-sandbox-demo.wasm` plugin is invoked multiple times, each time attempting different filesystem operations against directories configured with different permission levels. + +**Test matrix:** + +| Directory | Permission | Allowed ops | Denied ops | +|-----------|-----------|-------------|------------| +| `data/rules/` | read-only | list, read | write, create | +| `data/cache/` | full-access | list, read, write, create, delete | — | +| `data/audit/` | drop-box | write, create | list, read | +| `data/counters/` | fixed-mutable | read, overwrite | create dir | +| `data/plugins/` | list-only | list filenames | read contents | +| `data/scratch/` | private-scratch | write, read | list dir | + +**Config** (`config/config_fs_sandbox_demo.yaml`): + +```yaml +sandbox: + filesystem: + - dir: "./examples/data/rules" + permission: read-only + - dir: "./examples/data/cache" + permission: full-access + - dir: "./examples/data/audit" + permission: drop-box + - dir: "./examples/data/counters" + permission: fixed-mutable + - dir: "./examples/data/plugins" + permission: list-only + - dir: "./examples/data/scratch" + permission: private-scratch +``` + +**What to look for in the output:** + +``` +[read-only] list_dir → OK +[read-only] read_file → OK +[read-only] write_file → DENIED (PluginResult::Block) +[drop-box] write_file → OK +[drop-box] list_dir → DENIED +[drop-box] read_file → DENIED +``` + +--- + +## Tutorial 4: Environment Variable Permissions + +**What you'll learn:** How `allowed_env` controls variable visibility, ensuring plugins cannot access credentials they don't need. + +{{< asciinema cast="https://asciinema.org/a/LQtm4ShjcehkFIPe.cast" poster="npt:0:03" >}} + + +**Run it:** + +```bash +make run-env-demo +``` + +**What happens:** + +The `env-sandbox-demo.wasm` plugin is invoked for each environment variable. It calls `std::env::var()` inside the sandbox and reports whether the variable was visible. + +**Variables tested:** + +| Variable | In `allowed_env`? | Result | +|----------|-------------------|--------| +| `CPEX_APP_TOKEN` | yes | Visible ✓ | +| `CPEX_LOG_LEVEL` | yes | Visible ✓ | +| `HOME` | no | Hidden ✗ | +| `PATH` | no | Hidden ✗ | +| `SECRET_API_KEY` | no | Hidden ✗ | + +**Config** (`config/config_env_sandbox_demo.yaml`): + +```yaml +sandbox: + env_vars: + - CPEX_APP_TOKEN + - CPEX_LOG_LEVEL +``` + +**Key insight:** The host sets actual values for allowed vars before entering the sandbox. The guest's `std::env::var("HOME")` returns `Err(NotPresent)` — the variable simply doesn't exist in the WASI context. + +--- + +## Tutorial 5: Network Permissions + +**What you'll learn:** How `NetworkPolicy` filters outbound HTTP requests by host, port, scheme, and method. + +{{< asciinema cast="https://asciinema.org/a/S41GAnzybvI3Fvyp.cast" poster="npt:0:03" >}} + +**Run it:** + +```bash +make run-network-policy-demo +``` + +**What happens:** + +Seven scenarios test different network policy dimensions using `net-http-test.wasm`: + +| Scenario | Policy | Request | Result | +|----------|--------|---------|--------| +| 1. No policy | `network: []` | Any URL | Blocked | +| 2. Host allowlist | `host: "api.example.com"` | `https://api.example.com/data` | Allowed | +| 3. Wrong host | `host: "api.example.com"` | `https://evil.com/steal` | Blocked | +| 4. Wildcard | `host: "*.example.com"` | `https://staging.example.com/` | Allowed | +| 5. Port enforcement | `ports: [443]` | Port 8080 request | Blocked | +| 6. Scheme enforcement | `schemes: ["https"]` | `http://` request | Blocked | +| 7. Method enforcement | `methods: ["GET"]` | POST request | Blocked | + +**Note:** This demo constructs `NetworkRule` configs programmatically (not from YAML) to demonstrate each dimension in isolation. In production, you'd configure these in the plugin's YAML sandbox block. + +**Key code pattern:** + +```rust +let policy = SandboxPolicy { + allowed_network: vec![ + NetworkRule { + host: "api.example.com".into(), + ports: vec![443], + schemes: vec!["https".into()], + methods: vec!["GET".into(), "POST".into()], + }, + ], + ..Default::default() +}; +``` + +--- + +## Tutorial 6: Resource Limits + +**What you'll learn:** How fuel, timeout, and memory limits protect the host from runaway plugins. + +{{< asciinema cast="https://asciinema.org/a/pua7El09LBPxY86k.cast" poster="npt:0:03" >}} + +**Run it:** + +```bash +make run-resource-limits-demo +``` + +**What happens:** + +The `resource-test.wasm` plugin is loaded three times, each with a deliberately tiny resource limit. The plugin attempts to exceed the limit and is cleanly terminated by the host. + +**Scenarios:** + +| Scenario | Limit | Plugin behavior | Expected result | +|----------|-------|----------------|-----------------| +| 1. Fuel exhaustion | `max_fuel: 10,000` | Tight arithmetic loop burning instructions | Trap — fuel exhausted | +| 2. Epoch timeout | `max_execution_time_ms: 200` | Infinite loop (500M fuel budget) | Trap — epoch deadline interrupted | +| 3. Memory limit | `max_memory_bytes: 5 MB` | Allocates 1 MB chunks in a loop | Trap — memory.grow denied | + +**Key code pattern:** + +```rust +let policy = SandboxPolicy { + resources: ResourceLimits { + max_fuel: Some(10_000), + max_execution_time_ms: Some(200), + max_memory_bytes: Some(5 * 1024 * 1024), + ..Default::default() + }, + ..Default::default() +}; +``` + +**What to look for in the output:** + +``` +Scenario 1: fuel exhaustion (max_fuel=10,000) + [TRAPPED] mode=burn_fuel limit=max_fuel=10,000 + → Plugin trapped in ~μs + +Scenario 2: epoch timeout (max_execution_time_ms=200) + [TRAPPED] mode=burn_fuel limit=max_execution_time_ms=200 + → Plugin trapped in ~200ms + +Scenario 3: memory limit (max_memory_bytes=5MB) + [TRAPPED] mode=alloc_memory limit=max_memory_bytes=5MB + → Plugin trapped in ~μs +``` + +Each trap cleanly drops the `Store`, freeing all plugin memory. The next invocation starts fresh with a new `Store`. + +**Related test (for CI):** + +```bash +cargo test -p cpex-wasm-host test_sandbox_resource_limits -- --ignored +``` diff --git a/docs/specs/cpex-wasm-spec.md b/docs/specs/cpex-wasm-spec.md new file mode 100644 index 00000000..100a88f7 --- /dev/null +++ b/docs/specs/cpex-wasm-spec.md @@ -0,0 +1,621 @@ +# CPEX WASM Plugin — Specification + +**Status**: Draft +**Date**: July 2026 +**Source**: `crates/cpex-wasm-host` + `crates/cpex-wasm-plugin` in `github.com/contextforge-org/contextforge-plugins-framework` + +CPEX WASM extends the core plugin runtime with sandboxed execution via WebAssembly Component Model (WASI P2). It serves two audiences: + +- **Operators** — deploy untrusted or third-party plugins with enforced resource limits, capability-gated data access, and network sandboxing. +- **Plugin authors** — write the same `HookHandler` implementations as native plugins, compiled to `.wasm` components that run in isolation. + +The WASM system integrates transparently with `PluginManager` — WASM and native plugins coexist in the same pipeline, dispatched by the same executor, subject to the same 5-phase execution model. + +--- + +## 1. Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ PluginManager │ +│ register_factory("wasm://...", WasmPluginFactory) │ +│ load_config(yaml) → factory.create(PluginConfig) │ +│ invoke_named::(hook, payload, ext, ctx_table) │ +└───────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Executor (cpex-core) │ +│ group_by_mode → 5-phase dispatch │ +│ filter_extensions(ext, capabilities) → filtered view │ +│ set write tokens (write_headers, append_labels, etc.) │ +│ timeout + on_error policy │ +└───────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ WasmBridgeHandler (cpex-wasm-host) │ +│ Payload dispatch: │ +│ MessagePayload → HookPayload::Cmf (structured) │ +│ IdentityPayload → HookPayload::Identity (structured) │ +│ DelegationPayload → HookPayload::Delegation (structured) │ +│ Custom types → HookPayload::Custom (JSON bytes) │ +│ native_extensions_to_wit(filtered) → WIT types │ +│ SandboxManager::invoke() → WASM execution │ +│ wit_hook_result_to_native_filtered() → ErasedResultFields │ +│ validate_extension_modifications() → accept or reject │ +└───────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Wasmtime Sandbox │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ SharedEngine (1 per factory) │ │ +│ │ Engine config: component-model + fuel + epoch │ │ +│ │ Linker: WASI P2 + WASI HTTP + host-logging │ │ +│ │ Epoch ticker: 1 thread, 1ms resolution │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ Store (1 per plugin, isolated) │ │ +│ │ WasmPluginState: WASI ctx, HTTP ctx, NetworkPolicy, │ │ +│ │ ResourceTable, StoreLimits, plugin_name│ │ +│ │ Fuel: reset per invocation │ │ +│ │ Epoch deadline: reset per invocation │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Guest Component │ │ +│ │ export: handle-hook(hook_name, payload, ext, ctx) │ │ +│ │ import: wasi:io, wasi:clocks, wasi:http, host-logging │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Crate Layout + +### cpex-wasm-host (host runtime) + +| Module | Purpose | +|--------|---------| +| `sandbox_manager` | Wasmtime engine, store, plugin loading, invocation, `SharedEngine` | +| `factory` | `WasmPluginFactory`, `WasmBridgeHandler` — integrates with `PluginManager` | +| `conversions` | Bidirectional native ↔ WIT type mapping (~1300 lines) | +| `policy_loader` | `SandboxPolicy` parsing, WASI context builder | +| `payload_registry` | Type-erased serialization for custom payload types | + +### cpex-wasm-plugin (guest SDK) + +| Module | Purpose | +|--------|---------| +| `lib.rs` | WIT bindings, `register_wasm_plugin!` macro, `host_log`/`cpex_log!` | +| `conversions.rs` | WIT → native type conversion for the guest side (~730 lines) | +| `plugins/` | Feature-gated demo plugins (identity-checker, header-injector, audit-logger, token-attenuator, noop) | + +--- + +## 3. WIT Interface Contract + +**Package:** `cpex:plugin` + +### 3.1 Types Interface + +Defines all structured types crossing the boundary: + +- **CMF**: `role`, `channel`, `resource-type`, `content-part` (12 variants), `message`, `message-payload` +- **Identity**: `identity-payload` with source, headers, output fields +- **Delegation**: `delegation-payload` with target, attenuation, output fields +- **Extensions**: All 11 slots (request, security, http, meta, agent, mcp, completion, provenance, llm, framework, delegation) + custom +- **Result**: `hook-result` with continue_processing, modified_payload, modified_extensions, modified_context, violation, metadata + +### 3.2 Host-Logging Interface + +```wit +interface host-logging { + enum log-level { trace, debug, info, warn, error } + log: func(level: log-level, message: string); +} +``` + +### 3.3 World Definition + +```wit +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; + import host-logging; + + export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context + ) -> hook-result; +} +``` + +--- + +## 4. Security Model + +### 4.1 Capability-Based Extension Filtering + +The executor filters extensions before they reach the handler. Slots without declared capabilities are `None`. The plugin never receives unauthorized data. + +| Capability | Read Access | Write Access | +|-----------|-------------|--------------| +| `read_labels` | Security labels | — | +| `append_labels` | — | Add labels (monotonic) | +| `read_subject` | Subject id + type | — | +| `read_roles` | Subject roles | — | +| `read_teams` | Subject teams | — | +| `read_claims` | Subject claims | — | +| `read_permissions` | Subject permissions | — | +| `read_client` | OAuth client | — | +| `read_workload` | Workload identity | — | +| `read_headers` | HTTP headers | — | +| `write_headers` | — | Modify HTTP headers | +| `read_agent` | Agent context | — | +| `read_delegation` | Delegation chain | — | +| `append_delegation` | — | Append to chain | +| `read_inbound_credentials` | Raw tokens | — | +| `read_delegated_tokens` | Minted tokens | — | + +### 4.2 Post-Invocation Validation (Defense-in-Depth) + +After the guest returns, the handler validates: + +1. **Immutable tier** — Arc pointer identity on immutable slots (request, agent, mcp, completion, provenance, llm, framework, meta). Tampering → reject all extension modifications. +2. **Monotonic tier** — Security labels must be a superset of the originals. Label removal → reject. +3. **Write authorization** — HTTP/labels/delegation modifications require the corresponding write capability. Unauthorized writes → reject. +4. **Filtered slot preservation** — Slots hidden from the guest are preserved unchanged. + +### 4.3 Sandbox Enforcement + +| Layer | Scope | Default | +|-------|-------|---------| +| Fuel budget | Per-invocation (reset each call) | Unlimited | +| Execution timeout | Per-invocation (epoch-based) | 5,000 ms | +| Memory limit | Store lifetime | Unlimited | +| Network allowlist | Per outbound HTTP request | Deny all | +| Filesystem | Store lifetime (preopened dirs) | Deny all | +| Environment variables | Store lifetime | None exposed | + +### 4.4 Credential Exclusion + +Fields marked `#[serde(skip)]` never cross the boundary: +- `RawInboundToken.token` +- `RawDelegatedToken.token` +- `IdentityPayload.raw_token` +- `DelegationPayload.bearer_token` + +--- + +## 5. Data Flow + +### 5.1 Outbound (Host → Guest) + +``` +PluginPayload + → downcast to MessagePayload? → native_payload_to_wit() → HookPayload::Cmf + → downcast to IdentityPayload? → native_identity_payload_to_wit() → HookPayload::Identity + → downcast to DelegationPayload? → native_delegation_payload_to_wit() → HookPayload::Delegation + → PayloadSerializerRegistry.serialize() → HookPayload::Custom(type_name, bytes) + +Extensions (already filtered by executor) + → native_extensions_to_wit() → WIT Extensions record + (each slot: Arc → field-by-field clone into WIT structs) + (JSON-encoded: custom slot, tool arguments, context entries) + +PluginContext + → native_context_to_wit() → WIT PluginContext + (local_state/global_state → JSON-encoded key-value pairs) +``` + +### 5.2 Inbound (Guest → Host) + +``` +HookResult + → modified_payload: + Cmf → wit_cmf_payload_to_native() + Identity → wit_identity_payload_to_native() + Delegation → wit_delegation_payload_to_native() + Custom → PayloadSerializerRegistry.deserialize() + → modified_extensions: + original.cow_copy() (preserves immutable Arc pointers) + overlay mutable slots ONLY if guest was authorized to see them + → modified_context: + write back local_state + global_state to caller's ctx reference + → violation: + wit_violation_to_native() +``` + +### 5.3 Error Classification + +Wasmtime errors are pattern-matched into `PluginError` variants: + +| Error pattern | Variant | Code | +|--------------|---------|------| +| "epoch deadline" | `Timeout` | — | +| "all fuel consumed" / "fuel" | `Execution` | `fuel_exhausted` | +| "memory" + "grow"/"limit" | `Execution` | `memory_limit` | +| "unreachable" / "wasm trap" / "panic" | `Execution` | `wasm_trap` | +| "request denied" | `Execution` | `network_denied` | +| Other | `Execution` | None | + +--- + +## 6. Plugin SDK + +### 6.1 The `register_wasm_plugin!` Macro + +```rust +register_wasm_plugin!(MyPlugin, [CmfHook, IdentityHook]); +``` + +Generates: +- A `Guest` implementation for the WIT `handle-hook` export +- Payload routing logic (CMF fast-path, Custom variant matching) +- Bidirectional type conversion calls +- A synchronous async executor (`__block_on`) for driving the handler future + +### 6.2 Structured Logging + +```rust +use crate::cpex_log; + +cpex_log!(info, "processing tool '{}' for subject {:?}", tool_name, subject_id); +cpex_log!(warn, "PII access without hr_admin role"); +``` + +Routes to the host's `tracing` subscriber with `plugin=` as a span field. +In `#[cfg(test)]` mode, falls back to `eprintln!` (no host import available natively). + +### 6.3 Available Hook Types + +| Hook Type | Payload | WIT Variant | +|-----------|---------|-------------| +| `CmfHook` | `MessagePayload` | `cmf` (structured) | +| `IdentityHook` | `IdentityPayload` | `identity` (structured) | +| `TokenDelegateHook` | `DelegationPayload` | `delegation` (structured) | +| Custom (via `define_hook!`) | Any `WasmSerializablePayload` | `custom` (JSON) | + +--- + +## 7. Configuration + +```yaml +plugins: + - name: my-plugin + kind: wasm://my-plugin.wasm # wasm:// prefix triggers WasmPluginFactory + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential # sequential|transform|audit|concurrent|fire_and_forget + priority: 50 # lower = earlier (within same mode) + on_error: fail # fail|ignore|disable (circuit breaker) + capabilities: # extension visibility + write grants + - read_labels + - append_labels + - read_headers + - write_headers + config: + sandbox_policy: + allowed_filesystem: [] # deny all by default + allowed_network: + - "api.internal.svc" # hostname allowlist (exact + subdomain match) + allowed_env: [] # env vars exposed to plugin + resources: + max_memory_bytes: 10485760 # 10 MB linear memory cap + max_fuel: 1000000000 # instructions per invocation (~1 billion) + max_execution_time_ms: 5000 # per-invocation timeout (epoch-based) + max_instances: 10 # WASM module instance limit + max_tables: 10 # WASM table limit +``` + +--- + +## 8. Performance Characteristics + +### 8.1 Benchmark Results + +| Scenario | Latency | Overhead vs Native | +|----------|:-------:|:------------------:| +| Native handler (noop) | 84 ns | 1x | +| Type conversion only (no WASM) | 843 ns | 10x | +| WASM noop (minimal payload) | 4.8 µs | 57x | +| WASM with full extensions | 7.9 µs | 94x | + +### 8.2 Throughput + +| Scenario | Calls/sec/core | +|----------|:--------------:| +| Native plugin | ~12,000,000 | +| WASM (minimal) | ~207,000 | +| WASM (full extensions) | ~126,000 | + +### 8.3 Cost Breakdown + +| Stage | Cost | +|-------|------| +| Mutex acquire | ~50 ns | +| Fuel + epoch reset | ~40 ns | +| Type conversion (native → WIT) | ~843 ns | +| Wasmtime component dispatch | ~2.5 µs | +| Guest execution (noop) | ~500 ns | +| Result conversion (WIT → native) | ~500 ns - 2 µs | +| Post-invocation validation | ~100 ns | + +### 8.4 Shared Engine Optimization + +All plugins from the same `WasmPluginFactory` share one `Engine` + one epoch ticker thread. N plugins = 1 thread (not N threads). Each plugin gets an isolated `Store` (separate memory, fuel, limits). + +--- + +## 9. Build & Test + +### 9.1 Building Plugins + +```bash +rustup target add wasm32-wasip2 +cd crates/cpex-wasm-plugin +cargo build --target wasm32-wasip2 --release --features --no-default-features +``` + +One binary per feature (WIT single-export constraint). + +### 9.2 Running Tests + +```bash +# Host-side (35 tests: security, conversions, errors, policy) +cargo test -p cpex-wasm-host + +# Plugin-side (12 tests: all 4 plugins × their hook types) +cargo test --manifest-path crates/cpex-wasm-plugin/Cargo.toml \ + --features identity-checker --no-default-features +cargo test --manifest-path crates/cpex-wasm-plugin/Cargo.toml \ + --features header-injector --no-default-features +cargo test --manifest-path crates/cpex-wasm-plugin/Cargo.toml \ + --features audit-logger --no-default-features +cargo test --manifest-path crates/cpex-wasm-plugin/Cargo.toml \ + --features token-attenuator --no-default-features +``` + +### 9.3 Running Benchmarks + +```bash +cd crates/cpex-wasm-plugin +cargo build --target wasm32-wasip2 --release --features noop --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/noop.wasm +cd ../.. +cargo bench -p cpex-wasm-host +``` + +--- + +## 10. Limitations + +### 10.1 Structural (inherent to WASM model) + +- One plugin per `.wasm` binary (WIT single-export) +- Single-threaded per plugin (Store is not Sync) +- No raw credential access (security boundary) +- Rust-only guest SDK + +### 10.2 Current Implementation + +- No pre-compiled module caching (compile from source each load) +- No plugin instance pooling (serial under concurrent load) +- No streaming support (full payload buffering) +- No schema versioning (WIT changes are breaking) +- No plugin manifest / health check protocol +- Epoch ticker thread never stops (leaks on dynamic unload) +- Error classification relies on wasmtime error message strings +- Silent `.ok()` on some serialization failures in conversions + +### 10.3 By Design + +- `#[serde(skip)]` fields excluded from WIT (credentials) +- Custom extension slot is unrestricted (no capability gating) +- Metadata field dropped at the erasure boundary (consistent with native) +- Guest stdio inherited from host (plugins should use `cpex_log!`) + +--- + +## 11. Design Decisions + +This section records the key architectural decisions and their rationale across both `cpex-wasm-host` and `cpex-wasm-plugin`. + +### 11.1 Transparent Integration via PluginFactory Trait + +**Decision:** WASM plugins register with the same `PluginManager` as native plugins via `WasmPluginFactory` implementing cpex-core's `PluginFactory` trait. + +**Rationale:** Operators should not need to change their pipeline configuration or dispatch logic when switching between native and WASM plugins. A WASM plugin and a native plugin can coexist in the same hook pipeline, dispatched by the same executor, subject to the same 5-phase execution model. This makes WASM an implementation detail — the security boundary is invisible to the orchestration layer. + +**Consequence:** `WasmBridgeHandler` implements `AnyHookHandler` with the same signature as native handlers. The executor calls `.invoke(payload, extensions, ctx)` identically for both. + +### 11.2 SharedEngine — One Epoch Ticker Per Factory + +**Decision:** All plugins loaded from a single `WasmPluginFactory` share one `wasmtime::Engine` and one epoch ticker thread (1ms resolution). Each plugin gets its own `Store` with independent memory, fuel, and state. + +**Rationale:** Epoch interruption requires a dedicated thread that calls `engine.increment_epoch()` in a loop. Without sharing, N plugins would spawn N threads doing identical work. Sharing the engine also enables future optimizations (module caching, ahead-of-time compilation) since compiled modules are engine-scoped. + +**Trade-off:** The epoch ticker thread spawned in `SharedEngine::new()` runs forever — there is no shutdown mechanism. This is an acknowledged leak on dynamic factory unload. + +### 11.3 Per-Invocation Resource Reset + +**Decision:** Both fuel budget and epoch deadline are reset at the start of every `invoke()` call. Memory limits are store-lifetime (not reset). + +**Rationale:** Fuel measures instruction cost and must be fresh each call to prevent a long-lived plugin from silently degrading after accumulating work. The epoch deadline is a wall-clock timeout — it must start from zero each invocation or a plugin that ran fast once could time out later despite being well-behaved. Memory, however, is additive (linear memory never shrinks) so the limit is structural. + +### 11.4 Dual Payload Path — Structured WIT vs JSON Bytes + +**Decision:** Built-in payloads (CMF, Identity, Delegation) use structured WIT types with field-by-field conversion. Custom payload types use `HookPayload::Custom(type_name, json_bytes)`. + +**Rationale:** Structured WIT types give zero-parsing overhead for the common case (most plugins handle CMF messages). For extensibility, the `Custom` variant lets users define arbitrary payload types without modifying the WIT contract. The host's `PayloadSerializerRegistry` maps `TypeId` → `(type_name, serialize_fn, deserialize_fn)` so the dispatch is O(1). + +**Trade-off:** Custom payloads pay a JSON serialization cost on both sides. The structured path for CMF/Identity/Delegation avoids this. + +### 11.5 Capability-Based Extension Filtering (Pre-Invocation) + +**Decision:** The executor strips extension slots the plugin has no declared capability for _before_ converting to WIT. The guest never receives unauthorized data. + +**Rationale:** Defense at the narrowest possible point. Even if the WIT conversion code has bugs, an unauthorized slot is `None` at the source. This is the primary access control mechanism; post-invocation validation is defense-in-depth. + +### 11.6 Post-Invocation 4-Layer Validation (Defense-in-Depth) + +**Decision:** After the guest returns, the host validates extension modifications through 4 independent checks before accepting them. + +**Layer 1 — Immutable tier:** Arc pointer identity on immutable slots (request, agent, mcp, completion, provenance, llm, framework, meta). A malicious guest that reconstructs a "similar" object is detected because the Arc address differs. + +**Layer 2 — Monotonic labels:** Security labels must be a superset of originals. Removal is never valid. + +**Layer 3 — Write authorization:** HTTP/labels/delegation mutations require the corresponding write capability. A plugin with `read_headers` but not `write_headers` cannot modify HTTP headers. + +**Layer 4 — Filtered slot preservation:** Slots hidden from the guest (filtered out pre-invocation) are preserved unchanged in the result. + +**Rationale:** The WASM boundary is a trust boundary. Unlike native plugins (which run in-process and are trusted to some degree), WASM plugins may be third-party. The 4-layer model ensures that even a fully compromised guest cannot escalate privileges beyond its declared capabilities. + +### 11.7 Fail-Closed on Decode Errors + +**Decision:** If a custom payload intended for a declared handler fails to deserialize, the plugin returns a deny violation (`wasm_payload_decode_error`), not an allow. + +**Rationale:** Silently allowing on a decode failure would skip whatever security check this plugin enforces. If an attacker can craft a payload that causes a parse error, they could bypass the plugin entirely. Failing closed ensures the worst case is a false deny (operational disruption), not a false allow (security bypass). + +### 11.8 Credential Exclusion from WIT Types + +**Decision:** Fields marked `#[serde(skip)]` on native types (raw tokens, bearer tokens, token bytes) are excluded from the WIT type definitions entirely — they do not exist in the schema. + +**Rationale:** Credential material should never cross the sandbox boundary. Even with capability gating, a compromised WASM guest could exfiltrate tokens through side channels (timing, memory patterns). By excluding credentials at the schema level, there is no path — intentional or accidental — for tokens to enter the guest's address space. + +### 11.9 Feature-Flag-Per-Plugin Binary + +**Decision:** Each plugin compiles to a separate `.wasm` binary via Cargo feature flags. The SDK crate has 13+ features, one per plugin. + +**Rationale:** The WIT Component Model exports a single world per component. A component can only export one `handle-hook` function. Multiple plugins in one binary would require a dispatch layer inside the guest — adding complexity and defeating the isolation model. One binary per plugin gives clean isolation: each has its own Store, memory space, and fault domain. + +**Consequence:** Build commands require `--features --no-default-features` and produce one `.wasm` artifact per invocation. + +### 11.10 Macro-Driven SDK (`register_wasm_plugin!`) + +**Decision:** Plugin authors never touch WIT types directly. The `register_wasm_plugin!` macro generates the complete `Guest` impl, including payload routing, type conversion, and the synchronous async executor. + +**Rationale:** The WIT-generated types are mechanically derived and verbose. Plugin authors should write standard `HookHandler` implementations (identical to native plugins) and get WASM compatibility for free. This lowers the barrier to entry and ensures all WASM plugins follow the same dispatch pattern. + +**Generated code includes:** +- `Guest::handle_hook()` with match arms for each payload variant +- Compile-time dispatch via `downcast_ref` (CMF) and `payload_type_name()` matching (Custom) +- `__block_on()` — a trivial poll-loop for driving the handler's `async` future synchronously + +### 11.11 Synchronous Async Executor (`__block_on`) + +**Decision:** WASM plugins use a no-op waker poll loop to drive `HookHandler::handle()` futures to completion synchronously. + +**Rationale:** WASM is single-threaded with no ambient async runtime (no tokio, no epoll). However, `HookHandler` is an `async_trait` for API consistency with native plugins. In practice, WASM handlers resolve on the first `poll()` since they cannot await network or timers directly. The trivial executor avoids pulling in an async runtime dependency that cannot function in WASM. + +**Trade-off:** If a handler ever yields `Pending` without external stimulus (impossible today), the loop spin-waits until fuel is exhausted. This is acceptable because WASM handlers are designed to be non-blocking. + +### 11.12 Network Allowlist via WASI HTTP Hooks + +**Decision:** Outbound HTTP requests are intercepted at the `WasiHttpHooks::send_request()` level and checked against a per-plugin hostname allowlist. Requests to non-allowed hosts return `ErrorCode::HttpRequestDenied`. + +**Rationale:** Importing `wasi:http/outgoing-handler` enables HTTP but does not inherently restrict destinations. The `NetworkPolicy` hook provides a clean interception point before any network I/O occurs. Matching is by exact hostname or subdomain (e.g., `api.example.com` matches allowed host `example.com`). + +**Consequence:** Network policy errors surface as `PluginError::Execution { code: "network_denied" }` — distinguishable from other failures for accurate error reporting. + +### 11.13 Filesystem via WASI Preopens (No World-Level Import) + +**Decision:** Filesystem access is controlled by WASI preopened directories in the Store context, not by importing `wasi:filesystem` at the WIT world level. + +**Rationale:** Importing filesystem interfaces at the world level makes them available unconditionally. Preopens are strictly additive and can be configured per-plugin: a plugin with no preopens has zero filesystem visibility regardless of what the WIT world declares. This gives operators fine-grained path-level control. + +### 11.14 JSON-as-String for Recursive/Complex WIT Fields + +**Decision:** Fields that are recursive or structurally complex (e.g., `prompt-result.messages`, `tool-call.arguments`, `context entries`) are serialized as JSON strings in the WIT interface. + +**Rationale:** WIT does not support recursive types, self-referential structures, or arbitrary-depth nesting. Rather than flattening these into deeply nested WIT records (which would be brittle and hard to evolve), they are encoded as JSON strings. The guest deserializes them locally. This is an explicit trade-off: slightly higher runtime cost for schema flexibility. + +### 11.15 Copy-on-Write Extension Mutation Model + +**Decision:** The host creates a CoW copy of extensions before passing to the guest. On return, only mutable slots that the guest was authorized to see are overlaid back. Immutable slots preserve their original Arc pointers. + +**Rationale:** The executor's mutation model requires that a plugin's modifications are validated before being applied. By operating on a copy, the original is never corrupted — even if validation rejects all changes. This also enables the immutable-tier check (Arc pointer identity comparison). + +### 11.16 Cross-Invocation State via WASM Linear Memory + +**Decision:** Module-level `static` variables in the guest persist across invocations because the Store (and thus linear memory) is kept alive between calls. The `OnceLock` pattern provides lazy initialization. + +**Rationale:** Some plugins need initialization state (config parsing, connection caches, lookup tables) that should not be rebuilt on every call. Since each plugin has a dedicated Store that lives for the factory's lifetime, linear memory serves as persistent storage naturally. No explicit state-management API is needed. + +**Constraint:** The guest struct is `Default::default()`-constructed on each call — instance fields do not persist. Only module-level statics survive across invocations. + +### 11.17 WIT Keyword Workarounds + +**Decision:** Several Rust type names conflict with WIT keywords. These are renamed at the WIT level: +- `Resource` (Rust enum) → `%resource` (WIT escaped keyword) +- `Return` (enum variant) → `return-complete` (WIT rename) +- `Resource` (struct) → `resource-info` (WIT rename) + +**Rationale:** WIT reserves `resource` and `return` as keywords. Rather than renaming the Rust types (which would break the native API), the WIT schema uses escaping or alternative names. The conversion layer maps between them. + +### 11.18 Structured Host Logging via WIT Import + +**Decision:** Guest plugins log through a WIT-imported `host-logging` interface that routes to the host's `tracing` subscriber with a `plugin=` span field. A `cpex_log!` macro provides ergonomic formatting. + +**Rationale:** Guest `eprintln!` goes to the host's inherited stdio — unstructured, unattributed, and lost in production. The host-logging import gives structured logs that participate in the operator's observability stack (filtering by plugin name, log level, etc.). In `#[cfg(test)]` mode, the import doesn't exist, so the macro falls back to `eprintln!`. + +### 11.19 Error Classification via String Matching + +**Decision:** Wasmtime errors are classified into `PluginError` variants by pattern-matching on the error message string (e.g., `"epoch deadline"` → `Timeout`, `"all fuel consumed"` → `Execution { code: "fuel_exhausted" }`). + +**Rationale:** Wasmtime does not expose structured error types for all failure modes. The error message is the only reliable signal for distinguishing timeout vs. fuel exhaustion vs. memory limit vs. trap. This enables the executor to apply correct `on_error` policies (circuit breakers, timeout logging, error aggregation) per failure type. + +**Acknowledged limitation:** This is fragile across wasmtime version upgrades if error messages change. A future improvement would be to match on wasmtime's `Trap` enum variants where available. + +### 11.20 Deny-All Default Policy + +**Decision:** When no `sandbox_policy` is configured (or all allowlists are empty), the plugin runs in a fully locked-down sandbox: no filesystem, no network, no environment variables. Resource limits default to unlimited (wasmtime defaults). + +**Rationale:** Secure-by-default. An operator who forgets to configure a policy gets maximum isolation, not maximum access. Plugins that need external access must explicitly declare it — the configuration is the audit trail. + +### 11.21 Type-Erased Payload Registry + +**Decision:** The `PayloadSerializerRegistry` maps `TypeId` → `(type_name, serialize_fn, deserialize_fn)` using closures that capture the concrete type. It is built once at factory creation and shared immutably. + +**Rationale:** The `WasmBridgeHandler` receives `&dyn PluginPayload` (type-erased). It needs to: +1. Determine if the payload type is known +2. Serialize it to a type-discriminated byte format +3. Later, deserialize the guest's returned custom payload back to a concrete type + +The registry solves this with O(1) `TypeId` lookup and O(1) `type_name` lookup, without requiring the handler to know about every possible payload type at compile time. + +### 11.22 Unhandled Payloads Return Allow + +**Decision:** If the guest receives a payload variant it has no handler for (e.g., a CMF hook plugin receiving a Delegation payload), it returns `continue_processing: true` with no modifications — equivalent to a no-op allow. + +**Rationale:** Consistent with native plugin behavior. A plugin is only registered for specific hooks. If the dispatcher sends it a payload type it doesn't handle (which can happen during pipeline evolution), silently allowing is correct — the plugin is not responsible for that payload type. + +**Contrast with 11.7:** Decode errors on a _declared_ handler fail closed. _Undeclared_ payload types pass through. The distinction is: "I handle this type but the data is corrupt" vs. "this type is not my responsibility." + +### 11.23 Extensions Record with 11 Typed Slots + Custom Escape Hatch + +**Decision:** The WIT `extensions` record has 11 explicitly typed optional slots (request, security, http, meta, agent, mcp, completion, provenance, llm, framework, delegation) plus a `custom: option` for arbitrary JSON. + +**Rationale:** Typed slots give the guest structured access to the most common extension data without JSON parsing. The `custom` slot enables forward-compatibility: new extension types can be added without a WIT schema change (at the cost of losing type safety for that slot). + +**Trade-off:** The custom slot is unrestricted by the capability model — any plugin can read/write it. This is by design: custom extensions are application-specific and cannot be pre-categorized into the capability taxonomy. + +### 11.24 WASI P2 (Preview 2) as the Target Platform + +**Decision:** Target `wasm32-wasip2` and use WASI P2 interfaces (version 0.2.6) for all host capabilities. + +**Rationale:** WASI P2 provides the Component Model, typed interfaces, and the `outgoing-handler` HTTP pattern. WASI P1 (preview 1) uses a POSIX-like model that doesn't support typed imports/exports or the Component Model. P2 is required for `wit-bindgen` and the `wasmtime::component` API. The 0.2.6 version is the latest stable specification at time of implementation. + +### 11.25 Mutex-Protected SandboxManager + +**Decision:** Each `WasmBridgeHandler` holds an `Arc>`. Invocations acquire the lock before calling into WASM. + +**Rationale:** A wasmtime `Store` is `!Sync` — it cannot be shared across threads. The Mutex serializes access. Under concurrent load, this means one plugin processes one request at a time (serial execution per plugin). This is an explicit trade-off: simplicity and safety over throughput. + +**Future path:** Instance pooling (multiple Stores per plugin, round-robin dispatch) would remove this bottleneck without changing the security model.