From 027455404e91aeb18507fec5cf280e0712a9154e Mon Sep 17 00:00:00 2001 From: brunota20 Date: Thu, 25 Jun 2026 20:10:00 -0300 Subject: [PATCH 1/4] chore(workspace): hoist [workspace.dependencies] + [workspace.lints] Adds a `[workspace.dependencies]` table to the root manifest consolidating every dep used by 2+ crates across the full nullis- shepherd stack (anyhow, thiserror, tokio, futures, serde, serde_json, tracing, tracing-subscriber, strum, alloy-*, cowprotocol, reqwest, wit-bindgen, clap). Per-crate manifests inherit with `dep.workspace = true`, and may add features per call site via `dep = { workspace = true, features = ["extra"] }`. Single-consumer deps (wasmtime, toml, redb, getrandom, url, hex, axum, rand, ...) stay per-crate. Adds `[workspace.lints]` with light-touch defaults: `dbg_macro` and `todo` denied via clippy, `unsafe_op_in_unsafe_fn` warned via rust. `unsafe_code = deny` cannot be applied workspace-wide because every wit-bindgen guest module emits an `unsafe extern "C"` shim. Also pre-declares `auto_impl` and `derive_more` in the workspace deps table so future `Arc` boundaries and newtype-heavy crates can opt in without touching the root manifest. The version-drift failure mode (cowprotocol pinned to `1.0.0-alpha` in nexum-engine but `1.0.0-alpha.3` in shepherd-sdk, flagged in the 2026-06-25 audit) is now impossible by construction: every consumer inherits the single workspace pin. Audit reference: milestone-rubric-grant-audit-2026-06-25.md, judgment calls 1 + 3. --- Cargo.toml | 85 ++++++++++++++++++++++++++++++++++ crates/nexum-engine/Cargo.toml | 9 ++-- modules/example/Cargo.toml | 5 +- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d14c23e8..aca3feae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,91 @@ edition = "2024" license = "AGPL-3.0" repository = "https://github.com/nullisLabs/shepherd" +# Shared dependency table. Only deps consumed by 2+ crates across the +# full workspace (nexum-engine + every downstream module crate) are +# hoisted here; single-consumer deps stay per-crate. Crates inherit +# with `dep.workspace = true` and may add features per call site via +# `dep = { workspace = true, features = ["extra"] }`. Version drift +# across crates (the failure mode that prompted hoisting in the first +# place, e.g. cowprotocol on `1.0.0-alpha` vs `1.0.0-alpha.3`) is now +# impossible by construction. +[workspace.dependencies] +# Error + async plumbing. +anyhow = "1" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +futures = "0.3" + +# Serde + config. +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Observability. +tracing = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "ansi", "json"] } + +# `strum::IntoStaticStr` on every error / event enum gives a free +# snake_case `&'static str` for every variant, which feeds directly +# into `metrics::counter!(..., "error_kind" => name)` and +# `tracing::warn!(error_kind = name, ...)` recordings without an +# ad-hoc `match err { ... => "connect" ... }` ladder per call site. +strum = { version = "0.26", features = ["derive"] } + +# `auto_impl::auto_impl(&, Arc, Box)` forwarding impls for traits +# held through smart pointers. Available workspace-wide so any future +# `Arc` boundary can opt in without touching root manifest. +auto_impl = "1" + +# `derive_more` newtype boilerplate (`Deref`, `From`, `Display`, ...). +# `default-features = false, features = ["full"]` keeps the proc-macro +# surface predictable; per-derive opt-in via the standard `#[derive(...)]` +# syntax. Available workspace-wide; not pulled in by default. +derive_more = { version = "1", default-features = false, features = ["full"] } + +# CLI parser. Used by every binary crate (engine, load-gen, +# orderbook-mock, shepherd-backtest) via the derive macro. +clap = { version = "4", features = ["derive"] } + +# alloy stack. Engine uses the full provider/transport surface; +# guest-facing crates use `alloy-primitives` + `alloy-sol-types` for +# typed protocol values. Pinned together so a single workspace bump +# moves every consumer at once. +alloy-primitives = { version = "1.5", default-features = false, features = ["std", "serde"] } +alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } +alloy-provider = { version = "1.5", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] } +alloy-rpc-types-eth = { version = "1.5", default-features = false, features = ["std"] } +alloy-transport-ws = { version = "1.5", default-features = false } + +# CoW Protocol bindings. Pinned to one version across the workspace +# (was `1.0.0-alpha` in engine vs `1.0.0-alpha.3` in SDK before +# hoisting). +cowprotocol = { version = "1.0.0-alpha.3", default-features = false } + +# HTTP transport for `cow_api::request` REST passthrough and the +# orderbook-mock test surface. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# `wit-bindgen` is consumed by every guest module crate (example + +# every strategy + every fixture). Hoisted so a single bump moves +# them in lock-step. +wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } + +# Workspace-standard lint set. New crates inherit via +# `[lints] workspace = true` in their package manifest. `unsafe_code` +# cannot be denied workspace-wide because every wit-bindgen guest +# module emits an `unsafe extern "C"` shim; modules carrying that +# macro keep the default-warn allowance, and unsafe in non-binding +# code still trips review by convention. +[workspace.lints.rust] +unsafe_op_in_unsafe_fn = "warn" + +[workspace.lints.clippy] +# Deny the easy footguns. Each crate carries its own narrower +# `#![deny(...)]` where the cost of a violation is high (e.g. the +# binary entrypoints carry `unused_crate_dependencies` warn). +dbg_macro = "deny" +todo = "deny" + [profile.dev] panic = "abort" diff --git a/crates/nexum-engine/Cargo.toml b/crates/nexum-engine/Cargo.toml index 65768c3a..bf5cc270 100644 --- a/crates/nexum-engine/Cargo.toml +++ b/crates/nexum-engine/Cargo.toml @@ -5,11 +5,14 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lints] +workspace = true + [dependencies] wasmtime = { version = "45", features = ["component-model"] } wasmtime-wasi = "45" -anyhow = "1" -tokio = { version = "1", features = ["full"] } +anyhow.workspace = true +tokio.workspace = true getrandom = "0.4" -serde = { version = "1", features = ["derive"] } +serde.workspace = true toml = "1" diff --git a/modules/example/Cargo.toml b/modules/example/Cargo.toml index d363ae2f..90390030 100644 --- a/modules/example/Cargo.toml +++ b/modules/example/Cargo.toml @@ -5,8 +5,11 @@ edition.workspace = true license.workspace = true repository.workspace = true +[lints] +workspace = true + [lib] crate-type = ["cdylib"] [dependencies] -wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } +wit-bindgen.workspace = true From be1972f57be77b92e7822ca1d05549a0a9af05ca Mon Sep 17 00:00:00 2001 From: brunota20 Date: Thu, 25 Jun 2026 20:27:10 -0300 Subject: [PATCH 2/4] feat(nexum-engine): migrate CLI from hand-rolled parser to clap Replaces the `std::env::args().skip(1)` walker with a `#[derive(clap:: Parser)]` struct so the engine binary picks up `--help`, `--version`, proper argument validation, and structured error reporting for free. The positional surface is preserved one-for-one (` [manifest-path]`); behaviour for callers that already pass two paths is identical. Help output now documents each argument inline rather than hiding the usage in an anyhow message that only fires on misuse. `clap.workspace = true` consumes the workspace dep added in the prior commit; no new direct version pin in this crate. Audit reference: milestone-rubric-grant-audit-2026-06-25.md, judgment call 2. --- crates/nexum-engine/Cargo.toml | 1 + crates/nexum-engine/src/main.rs | 48 +++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/crates/nexum-engine/Cargo.toml b/crates/nexum-engine/Cargo.toml index bf5cc270..926b11d6 100644 --- a/crates/nexum-engine/Cargo.toml +++ b/crates/nexum-engine/Cargo.toml @@ -12,6 +12,7 @@ workspace = true wasmtime = { version = "45", features = ["component-model"] } wasmtime-wasi = "45" anyhow.workspace = true +clap.workspace = true tokio.workspace = true getrandom = "0.4" serde.workspace = true diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index f013f229..ec09672b 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -2,11 +2,39 @@ mod manifest; use std::path::PathBuf; use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use clap::Parser; use wasmtime::component::{Component, Linker, ResourceTable}; use wasmtime::error::Context as _; use wasmtime::{Engine, Store}; use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; +/// Reference CLI for the 0.2 `nexum-engine` runtime. +/// +/// Loads a Wasm Component, links the `shepherd:cow/shepherd` host +/// world plus the WASI p2 set, calls `init` once, then dispatches a +/// single synthetic block event so the host stubs exercise their +/// timing paths. Production deployments invoke the engine through +/// the supervisor entrypoint introduced in later milestones; this +/// CLI is the M1 smoke-test surface. +#[derive(Parser, Debug)] +#[command( + name = "nexum-engine", + about = "Load a Wasm Component and dispatch a synthetic block event", + long_about = None, + version, +)] +struct Cli { + /// Path to the Wasm Component file to load. + wasm_path: PathBuf, + + /// Optional explicit path to the module's `nexum.toml` manifest. + /// When omitted, the engine looks for `nexum.toml` next to the + /// component file and falls back to a permissive default (with + /// a deprecation warning) when none is found. + manifest_path: Option, +} + // Both packages are listed explicitly so wit-parser can resolve the // cross-package reference natively — no vendored deps/ tree needed. // World name is fully qualified. @@ -339,22 +367,20 @@ impl nexum::host::http::Host for HostState { #[tokio::main] async fn main() -> anyhow::Result<()> { - let mut args = std::env::args().skip(1); - let wasm_path = args.next().ok_or_else(|| { - anyhow::anyhow!("usage: nexum-engine []") - })?; - let explicit_manifest = args.next().map(PathBuf::from); + let cli = Cli::parse(); + let wasm_path = cli.wasm_path; + let explicit_manifest = cli.manifest_path; - println!("nexum-engine: loading component from {wasm_path}"); + println!( + "nexum-engine: loading component from {}", + wasm_path.display() + ); // Load the manifest from the explicit path if given, otherwise from // `nexum.toml` next to the component file. Missing → fallback (with // deprecation warning). - let manifest_path = explicit_manifest.or_else(|| { - PathBuf::from(&wasm_path) - .parent() - .map(|p| p.join("nexum.toml")) - }); + let manifest_path = + explicit_manifest.or_else(|| wasm_path.parent().map(|p| p.join("nexum.toml"))); let loaded = match manifest_path.as_deref() { Some(p) if p.exists() => { println!("nexum-engine: loading manifest from {}", p.display()); From 0b37c7007f44410de151e237d07c980c0d65fa0b Mon Sep 17 00:00:00 2001 From: brunota20 Date: Thu, 25 Jun 2026 20:28:03 -0300 Subject: [PATCH 3/4] docs(07-rpc-namespace-design): mark allowlist enforcement as future direction A casual reader of `07-rpc-namespace-design.md` hitting the file top or the "Method Allowlisting" subsection could plausibly walk away believing the 0.2 runtime gates RPC methods on a read-only allowlist and intercepts signing methods to delegate them to the identity backend. The shipped host implementation does neither: `chain::request` forwards any method string through to the configured alloy provider. Adds an explicit `Status: Future direction (0.3+ target)` callout both at the file top and right above the "Method Allowlisting" subsection so the gap between design intent and shipped behaviour is visible without having to scroll the design narrative end-to-end. Audit reference: milestone-rubric-grant-audit-2026-06-25.md, judgment call 4. --- docs/07-rpc-namespace-design.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index 8f443a3b..8cd1c935 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -1,5 +1,15 @@ # RPC Namespace Design: Generic JSON-RPC Passthrough +> **Status: Future direction (0.3+ target).** The 0.2 reference runtime +> ships the single `chain::request(chain_id, method, params)` WIT entry +> point and forwards any method to the configured alloy provider without +> a per-module allowlist. The "Method Allowlisting" section below +> describes the **intended** 0.3+ enforcement (read-only default set, +> per-module `[module.chain] extra_allowed_methods`, identity-delegated +> signing methods); none of it is wired into `chain::request` in the +> shipped binary. Treat the section as design intent until a tracking +> issue lands the runtime check. + > **Naming note (0.2):** This document describes the `chain` interface in the > `nexum:host` WIT package. In the 0.1 design history it was called `chain` > (short for "consensus"); 0.2 renamed it to `chain` because `chain.request(...)` @@ -212,6 +222,15 @@ That's it. The alloy provider already has the timeout/retry/rate-limit/fallback ### Method Allowlisting +> **Status: Future direction (0.3+ target).** The shipped 0.2 host +> implementation of `chain::request` forwards any method string to +> the alloy provider; it does **not** consult a read-only allowlist +> and it does **not** intercept signing methods to delegate to the +> identity backend. The categorisation below is the planned 0.3 +> enforcement model. Until that tracking issue lands the gating +> code, operators must treat any chain-capable module as having +> access to the full RPC surface their configured provider exposes. + The host maintains two categories of methods: **read-only methods** (always allowed through the RPC passthrough) and **signing methods** (delegated to the `identity` backend). #### Read-Only Methods (RPC Passthrough) From b86d4555d00fccdc4c9c408e66e64d49a6255879 Mon Sep 17 00:00:00 2001 From: Jean Neiverth Date: Mon, 29 Jun 2026 18:48:55 -0300 Subject: [PATCH 4/4] fix(nexum-engine): harden extract_host against fragment/query bypass (COW-1096) extract_host used find('/').or_else(find('?')) to locate the host boundary, so a fragment containing '/' (e.g. evil.com#/good.com) or a query string with an embedded URL could trick the parser into extracting the wrong host. Fix by taking the minimum position of '/', '?', and '#' per RFC 3986. Add 13 new tests covering adversarial URL shapes (fragments, query strings with slashes, port stripping, loopback/localhost, IPv6, percent-encoding, empty hosts) and host_allowed bypass attempts (suffix injection, case sensitivity, empty allowlist, wildcard semantics). --- crates/nexum-engine/src/manifest.rs | 108 +++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 3 deletions(-) diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs index 522e1687..8301685a 100644 --- a/crates/nexum-engine/src/manifest.rs +++ b/crates/nexum-engine/src/manifest.rs @@ -210,9 +210,13 @@ pub fn extract_host(url: &str) -> Option<&str> { let after_scheme = url .strip_prefix("https://") .or_else(|| url.strip_prefix("http://"))?; - let host_end = after_scheme - .find('/') - .or_else(|| after_scheme.find('?')) + // Authority ends at the first `/`, `?`, or `#` — take the minimum + // position so that fragments and query strings containing `/` don't + // pollute the host extraction (COW-1096). + let host_end = [after_scheme.find('/'), after_scheme.find('?'), after_scheme.find('#')] + .into_iter() + .flatten() + .min() .unwrap_or(after_scheme.len()); let host = &after_scheme[..host_end]; // strip optional user-info and port. @@ -262,4 +266,102 @@ mod tests { assert!(!host_allowed("discord.com", &allow)); assert!(!host_allowed("nope.example", &allow)); } + + // -- extract_host edge-case tests (COW-1096 and related) -- + + #[test] + fn extract_host_fragment_with_slash() { + // COW-1096: fragment containing / should not affect host extraction + assert_eq!(extract_host("https://evil.com#/good.com"), Some("evil.com")); + assert_eq!(extract_host("https://evil.com#frag/path"), Some("evil.com")); + } + + #[test] + fn extract_host_query_with_slash() { + assert_eq!( + extract_host("https://evil.com?redirect=https://good.com/path"), + Some("evil.com"), + ); + } + + #[test] + fn extract_host_strips_port() { + assert_eq!(extract_host("https://example.com:8443/path"), Some("example.com")); + assert_eq!(extract_host("https://example.com:80"), Some("example.com")); + } + + #[test] + fn extract_host_ipv4_loopback() { + assert_eq!(extract_host("https://127.0.0.1/path"), Some("127.0.0.1")); + assert_eq!(extract_host("https://0.0.0.0/path"), Some("0.0.0.0")); + } + + #[test] + fn extract_host_localhost() { + assert_eq!(extract_host("https://localhost/path"), Some("localhost")); + assert_eq!(extract_host("https://localhost:3000/path"), Some("localhost")); + } + + #[test] + fn extract_host_ipv6() { + // IPv6 brackets: the naive `:` split for port-stripping breaks + // bracketed IPv6 — returns "[" instead of "[::1]". Documenting + // current behaviour; proper IPv6 handling is out of scope for M1. + let result = extract_host("https://[::1]/path"); + assert_eq!(result, Some("[")); + } + + #[test] + fn extract_host_percent_encoded() { + // URL-encoded characters are returned as-is (no decoding). + assert_eq!(extract_host("https://evil%2Ecom/path"), Some("evil%2Ecom")); + } + + #[test] + fn extract_host_empty_and_edge_cases() { + assert_eq!(extract_host("https:///path"), None); // empty host + assert_eq!(extract_host("https://"), None); // nothing after scheme + } + + // -- host_allowed bypass-attempt tests -- + + #[test] + fn host_allowed_rejects_localhost_and_loopback() { + let allow = vec!["api.cow.fi".to_string()]; + assert!(!host_allowed("localhost", &allow)); + assert!(!host_allowed("127.0.0.1", &allow)); + assert!(!host_allowed("0.0.0.0", &allow)); + } + + #[test] + fn host_allowed_case_insensitive() { + let allow = vec!["Api.Cow.Fi".to_string()]; + assert!(host_allowed("api.cow.fi", &allow)); + assert!(host_allowed("API.COW.FI", &allow)); + assert!(host_allowed("Api.Cow.Fi", &allow)); + } + + #[test] + fn host_allowed_wildcard_does_not_match_base_domain() { + // *.example.com should NOT match example.com itself + let allow = vec!["*.example.com".to_string()]; + assert!(!host_allowed("example.com", &allow)); + assert!(host_allowed("sub.example.com", &allow)); + assert!(host_allowed("deep.sub.example.com", &allow)); + } + + #[test] + fn host_allowed_empty_allowlist_denies_all() { + let allow: Vec = vec![]; + assert!(!host_allowed("anything.com", &allow)); + assert!(!host_allowed("localhost", &allow)); + } + + #[test] + fn host_allowed_rejects_suffix_injection() { + // "evil-api.cow.fi" should NOT match allowlisted "api.cow.fi" + let allow = vec!["api.cow.fi".to_string()]; + assert!(!host_allowed("evil-api.cow.fi", &allow)); + assert!(!host_allowed("notapi.cow.fi", &allow)); + } }