From b8d14629f2401a491647e374baa08d5c2807eb21 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 12:36:48 -0700 Subject: [PATCH 1/5] test(connector): prove the sandbox limits fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WASM sandbox built fuel, memory and epoch limits but no test ran a guest that breached any of them: the existing coverage stopped at engine creation, the StoreLimits struct, and hash verification. A regression that dropped a limiter would have shipped green. wasm/tests/limits.rs runs real WAT guests against each limit — an infinite loop under a 10k fuel budget, a memory.grow one page past the cap, and an infinite loop under a one-second epoch deadline with fuel set to u64::MAX so nothing but the deadline can stop it — plus the two under-the-limit cases that prove the limits are not simply failing everything. Fuel exhaustion and epoch interruption were both mapped to ConnectorError::Sandbox(String) by substring-matching the trap message, which no caller could branch on. They are now classified from wasmtime::Trap into ConnectorError::FuelExhausted (already present) and a new ConnectorError::Timeout, and the fuel error reports what the guest actually burned. template_resolve gains the payload re-expansion test: a trigger value that looks like ${trigger.secret} is substituted verbatim, never re-expanded (finding 84). Findings 81, 84. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- crates/springtale-connector/src/error.rs | 3 + .../src/wasm/connector.rs | 131 ++++++++++---- crates/springtale-connector/src/wasm/mod.rs | 3 + .../src/wasm/tests/limits.rs | 162 ++++++++++++++++++ .../src/wasm/tests/mod.rs | 3 + .../src/rule/template_resolve.rs | 15 ++ 6 files changed, 286 insertions(+), 31 deletions(-) create mode 100644 crates/springtale-connector/src/wasm/tests/limits.rs create mode 100644 crates/springtale-connector/src/wasm/tests/mod.rs diff --git a/crates/springtale-connector/src/error.rs b/crates/springtale-connector/src/error.rs index 3ee6e6ad..86e4f9a5 100644 --- a/crates/springtale-connector/src/error.rs +++ b/crates/springtale-connector/src/error.rs @@ -34,6 +34,9 @@ pub enum ConnectorError { #[error("WASM memory limit exceeded")] MemoryLimitExceeded, + #[error("WASM execution exceeded wall-clock timeout ({limit_secs}s)")] + Timeout { limit_secs: u64 }, + #[error("WASM binary hash mismatch")] WasmHashMismatch, diff --git a/crates/springtale-connector/src/wasm/connector.rs b/crates/springtale-connector/src/wasm/connector.rs index a062c544..bf2bc671 100644 --- a/crates/springtale-connector/src/wasm/connector.rs +++ b/crates/springtale-connector/src/wasm/connector.rs @@ -89,6 +89,29 @@ pub struct WasmConnectorHost { sandbox_limits: SandboxLimits, } +/// Classify a Wasmtime call failure into a typed sandbox error. +/// +/// Fuel exhaustion and epoch (wall-clock) interruption are separate +/// limits with separate operator responses, so each gets its own +/// `ConnectorError` variant rather than a formatted `Sandbox` string +/// that callers would have to substring-match. +fn trap_to_error( + err: &wasmtime::Error, + limits: &SandboxLimits, + fuel_remaining: u64, +) -> ConnectorError { + match err.downcast_ref::() { + Some(wasmtime::Trap::OutOfFuel) => ConnectorError::FuelExhausted { + used: limits.fuel.saturating_sub(fuel_remaining), + limit: limits.fuel, + }, + Some(wasmtime::Trap::Interrupt) => ConnectorError::Timeout { + limit_secs: limits.timeout_secs, + }, + _ => ConnectorError::Sandbox(format!("execution failed: {err}")), + } +} + impl WasmConnectorHost { /// Create a new WASM connector host from compiled module + manifest, /// registering the module against a shared tier cache. @@ -231,37 +254,28 @@ impl ConnectorHost for WasmConnectorHost { .copy_from_slice(input_bytes); // Call guest execute function - let result_ptr = execute_fn - .call( - &mut store, - ( - i32::try_from(action_offset) - .map_err(|_| ConnectorError::Sandbox("action offset exceeds i32".into()))?, - i32::try_from(action_bytes.len()) - .map_err(|_| ConnectorError::Sandbox("action length exceeds i32".into()))?, - i32::try_from(input_offset) - .map_err(|_| ConnectorError::Sandbox("input offset exceeds i32".into()))?, - i32::try_from(input_bytes.len()) - .map_err(|_| ConnectorError::Sandbox("input length exceeds i32".into()))?, - ), - ) - .map_err(|e| { - // Check if this was a fuel exhaustion or epoch timeout - let msg = e.to_string(); - if msg.contains("fuel") { - ConnectorError::Sandbox(format!( - "connector exceeded instruction limit ({} fuel)", - self.sandbox_limits.fuel - )) - } else if msg.contains("epoch") { - ConnectorError::Sandbox(format!( - "connector exceeded timeout ({}s)", - self.sandbox_limits.timeout_secs - )) - } else { - ConnectorError::Sandbox(format!("execution failed: {e}")) - } - })?; + let call_result = execute_fn.call( + &mut store, + ( + i32::try_from(action_offset) + .map_err(|_| ConnectorError::Sandbox("action offset exceeds i32".into()))?, + i32::try_from(action_bytes.len()) + .map_err(|_| ConnectorError::Sandbox("action length exceeds i32".into()))?, + i32::try_from(input_offset) + .map_err(|_| ConnectorError::Sandbox("input offset exceeds i32".into()))?, + i32::try_from(input_bytes.len()) + .map_err(|_| ConnectorError::Sandbox("input length exceeds i32".into()))?, + ), + ); + let result_ptr = match call_result { + Ok(ptr) => ptr, + Err(e) => { + // `get_fuel` is read before the store is dropped so the + // fuel error can report what the guest actually burned. + let remaining = store.get_fuel().unwrap_or(0); + return Err(trap_to_error(&e, &self.sandbox_limits, remaining)); + } + }; // Read result from guest memory // Convention: result_ptr points to a JSON string in guest memory @@ -362,6 +376,61 @@ impl ConnectorHost for WasmConnectorHost { } } +/// Test-only execution helpers. +/// +/// The production path (`execute_checked`) requires a guest that follows +/// the four-argument `execute` ABI. The sandbox-limit tests need to run +/// a bare export under the same store, limiter, fuel budget and epoch +/// deadline, so these two helpers reuse `create_store` and the tier +/// cache and stop short of the JSON marshalling. +#[cfg(test)] +impl WasmConnectorHost { + /// Fresh store + instance at the checker's default tier. + fn instantiate_for_test( + &self, + ) -> Result<(Store, wasmtime::Instance), ConnectorError> { + let checker = CapabilityChecker::new(); + let mut store = self.create_store(&checker); + let instance = + self.tier_cache + .instantiate_at_tier(self.module_key(), checker.tier(), &mut store)?; + Ok((store, instance)) + } + + /// Call a zero-argument, zero-result export under the sandbox limits. + /// Returns the typed error when a limit fires. + pub(crate) fn execute_raw(&self, export: &str) -> Result<(), ConnectorError> { + let (mut store, instance) = self.instantiate_for_test()?; + let func = instance + .get_typed_func::<(), ()>(&mut store, export) + .map_err(|e| ConnectorError::Sandbox(format!("missing '{export}' export: {e}")))?; + match func.call(&mut store, ()) { + Ok(()) => Ok(()), + Err(e) => { + let remaining = store.get_fuel().unwrap_or(0); + Err(trap_to_error(&e, &self.sandbox_limits, remaining)) + } + } + } + + /// Run a zero-argument export, then report the guest's linear memory + /// size in bytes — what the `StoreLimits` memory cap governs. + pub(crate) fn memory_size_after(&self, export: &str) -> Result { + let (mut store, instance) = self.instantiate_for_test()?; + let func = instance + .get_typed_func::<(), ()>(&mut store, export) + .map_err(|e| ConnectorError::Sandbox(format!("missing '{export}' export: {e}")))?; + if let Err(e) = func.call(&mut store, ()) { + let remaining = store.get_fuel().unwrap_or(0); + return Err(trap_to_error(&e, &self.sandbox_limits, remaining)); + } + let memory = instance + .get_memory(&mut store, "memory") + .ok_or_else(|| ConnectorError::Sandbox("guest has no 'memory' export".into()))?; + Ok(memory.data_size(&store)) + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { diff --git a/crates/springtale-connector/src/wasm/mod.rs b/crates/springtale-connector/src/wasm/mod.rs index 1dacd91b..b5e0c8a1 100644 --- a/crates/springtale-connector/src/wasm/mod.rs +++ b/crates/springtale-connector/src/wasm/mod.rs @@ -6,6 +6,9 @@ pub mod runtime; pub mod tier; pub mod wasi; +#[cfg(test)] +mod tests; + pub use connector::WasmConnectorHost; pub use limits::SandboxLimits; pub use runtime::WasmEngine; diff --git a/crates/springtale-connector/src/wasm/tests/limits.rs b/crates/springtale-connector/src/wasm/tests/limits.rs new file mode 100644 index 00000000..87dd7245 --- /dev/null +++ b/crates/springtale-connector/src/wasm/tests/limits.rs @@ -0,0 +1,162 @@ +//! The sandbox limits fire. +//! +//! `wasm/runtime.rs` builds `StoreLimits` and `wasm/connector.rs` sets +//! the fuel budget and epoch deadline. These tests run real guests that +//! each breach one limit and assert the matching typed error, so a +//! regression that drops a limiter fails a test instead of silently +//! handing community connectors an unmetered sandbox. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use springtale_crypto::signature::SignatureAlgorithm; + +use crate::error::ConnectorError; +use crate::manifest::types::ConnectorManifest; +use crate::wasm::connector::WasmConnectorHost; +use crate::wasm::limits::SandboxLimits; +use crate::wasm::runtime::WasmEngine; +use crate::wasm::tier::WasmTierCache; + +/// 64 KiB — the WASM linear-memory page size. +const PAGE_BYTES: usize = 65_536; + +/// Minimal manifest for a guest that declares no capabilities. +fn test_manifest() -> ConnectorManifest { + ConnectorManifest { + name: "connector-limits-test".into(), + version: "0.1.0".into(), + author: "test".into(), + description: "sandbox limit guest".into(), + capabilities: vec![], + triggers: vec![], + actions: vec![], + data_disclosure: vec![], + roles: vec![], + wasm_hash: None, + signature_alg: SignatureAlgorithm::default(), + signature: None, + } +} + +/// Compile a WAT guest into a host running under `limits`. +fn host(limits: SandboxLimits, wat: &str) -> (Arc, WasmConnectorHost) { + let engine = Arc::new(WasmEngine::new(limits.clone()).expect("engine")); + let cache = Arc::new(WasmTierCache::new(engine.clone()).expect("tier cache")); + let bytes = wat::parse_str(wat).expect("valid wat"); + let host = WasmConnectorHost::new(engine.clone(), &bytes, test_manifest(), limits, cache) + .expect("host"); + (engine, host) +} + +#[test] +fn fuel_exhaustion_traps() { + let limits = SandboxLimits { + fuel: 10_000, + ..SandboxLimits::default() + }; + let (_engine, host) = host(limits, r#"(module (func (export "spin") (loop br 0)))"#); + + let err = host + .execute_raw("spin") + .expect_err("infinite loop must trap"); + assert!( + matches!(err, ConnectorError::FuelExhausted { limit, .. } if limit == 10_000), + "{err:?}" + ); +} + +#[test] +fn a_guest_under_the_fuel_budget_returns() { + let limits = SandboxLimits { + fuel: 10_000, + ..SandboxLimits::default() + }; + let (_engine, host) = host(limits, r#"(module (func (export "noop")))"#); + + assert!(host.execute_raw("noop").is_ok()); +} + +#[test] +fn memory_growth_past_limit_is_refused() { + // Cap at 1 MiB so the guest's 1 MiB + 1 page request is one page over. + let cap = 16 * PAGE_BYTES; + let over = u32::try_from(cap / PAGE_BYTES + 1).expect("page count fits i32"); + let limits = SandboxLimits { + memory_bytes: cap, + ..SandboxLimits::default() + }; + // `memory.grow` returns -1 when the limiter refuses; the guest traps + // via `unreachable` if the growth was allowed, so a passing call is + // itself proof the cap held. + let wat = format!( + r#"(module + (memory (export "memory") 1) + (func (export "grow_past_cap") + (if (i32.ne (memory.grow (i32.const {over})) (i32.const -1)) + (then unreachable))))"# + ); + let (_engine, host) = host(limits, &wat); + + host.execute_raw("grow_past_cap") + .expect("growth past the cap must be refused, not granted"); + let size = host + .memory_size_after("grow_past_cap") + .expect("memory size"); + assert!(size <= cap, "guest holds {size} bytes, cap is {cap}"); +} + +#[test] +fn memory_growth_within_limit_succeeds() { + let cap = 16 * PAGE_BYTES; + let limits = SandboxLimits { + memory_bytes: cap, + ..SandboxLimits::default() + }; + let wat = r#"(module + (memory (export "memory") 1) + (func (export "grow_one") (drop (memory.grow (i32.const 1)))))"#; + let (_engine, host) = host(limits, wat); + + let size = host.memory_size_after("grow_one").expect("memory size"); + assert_eq!(size, 2 * PAGE_BYTES); +} + +#[test] +fn epoch_deadline_traps_without_fuel_running_out() { + // Unbounded fuel isolates the epoch deadline: whatever stops this + // guest, it is not the instruction meter. + let limits = SandboxLimits { + fuel: u64::MAX, + timeout_secs: 1, + ..SandboxLimits::default() + }; + let (engine, host) = host(limits, r#"(module (func (export "spin") (loop br 0)))"#); + + // The deadline is measured in epoch ticks; something outside the + // engine has to advance them. In production that is the daemon's + // ticker task. + let ticker = engine.engine().clone(); + let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop = done.clone(); + let handle = thread::spawn(move || { + while !stop.load(std::sync::atomic::Ordering::Relaxed) { + thread::sleep(Duration::from_millis(20)); + ticker.increment_epoch(); + } + }); + + let err = host + .execute_raw("spin") + .expect_err("infinite loop must trap"); + done.store(true, std::sync::atomic::Ordering::Relaxed); + handle.join().expect("ticker thread"); + + assert!( + matches!(err, ConnectorError::Timeout { limit_secs } if limit_secs == 1), + "{err:?}" + ); +} diff --git a/crates/springtale-connector/src/wasm/tests/mod.rs b/crates/springtale-connector/src/wasm/tests/mod.rs new file mode 100644 index 00000000..26320b93 --- /dev/null +++ b/crates/springtale-connector/src/wasm/tests/mod.rs @@ -0,0 +1,3 @@ +//! Test-only modules for the WASM sandbox. + +mod limits; diff --git a/crates/springtale-core/src/rule/template_resolve.rs b/crates/springtale-core/src/rule/template_resolve.rs index 418975cd..6fb58226 100644 --- a/crates/springtale-core/src/rule/template_resolve.rs +++ b/crates/springtale-core/src/rule/template_resolve.rs @@ -243,6 +243,21 @@ mod tests { ChainContext::new(trigger) } + /// A `${...}` sequence that arrives *inside* a payload value is data, + /// not a template. Resolution is a single pass over the rule string, + /// so a trigger field whose value happens to look like a reference is + /// substituted verbatim and never re-expanded — the guard that keeps + /// an attacker-controlled webhook body from reaching a secret it was + /// never given. + #[test] + fn payload_values_are_not_re_expanded() { + let c = ctx_with(json!({ "name": "${trigger.secret}", "secret": "s3cr3t" })); + assert_eq!( + resolve_chain_template("hi ${trigger.name}", &c, None), + "hi ${trigger.secret}" + ); + } + #[test] fn resolves_trigger_field() { let mut c = ctx_with(json!({ "chat_id": 42, "name": "alice" })); From 4cac2ef810da4b1220b976b021cd85983dad2684 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 12:42:25 -0700 Subject: [PATCH 2/5] deps: bump chitchat 0.10 -> 0.13, dropping the unsound lru MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chitchat 0.10 pinned lru 0.13.0, which carries RUSTSEC-2026-0002 (IterMut Stacked Borrows unsoundness, fixed in 0.18.2). No 0.10.x release moved off it, so the advisory sat in both ignore lists with a VEX note instead of being fixed. chitchat 0.13.0 depends on lru 0.18.4, and the workspace now resolves a single patched lru. Two API changes in the major bump, both in awareness/store/chitchat.rs: ChitchatId::node_id is now Arc, and ChitchatConfig gained an explicit protocol_version. V0 is chitchat's own default and the wire format 0.10 spoke, so the bump changes no bytes on the wire. The RUSTSEC-2026-0002 ignore is removed from .cargo/audit.toml and deny.toml and the VEX record deleted — the advisory no longer matches any crate in the graph. Neither audit configuration was relaxed. cargo audit and cargo deny check advisories both pass without it. Finding 125. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .cargo/audit.toml | 6 --- Cargo.lock | 37 ++++++++++--------- Cargo.toml | 2 +- .../src/awareness/store/chitchat.rs | 14 +++++-- deny.toml | 1 - vex/lru-rustsec-2026-0002.json | 29 --------------- 6 files changed, 31 insertions(+), 58 deletions(-) delete mode 100644 vex/lru-rustsec-2026-0002.json diff --git a/.cargo/audit.toml b/.cargo/audit.toml index b4aa3d59..f9e5fe57 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -35,12 +35,6 @@ ignore = [ # upstream issue. See `vex/instant-rustsec-2024-0384.json`. "RUSTSEC-2024-0384", - # lru 0.13 IterMut Stacked Borrows soundness — pulled transitively via - # chitchat (workspace SWIM/scuttlebutt). Springtale doesn't call - # lru::IterMut directly; chitchat is on the dependabot ignore list (wire - # format critical). See `vex/lru-rustsec-2026-0002.json`. - "RUSTSEC-2026-0002", - # proc-macro-error2 2.0.1 unmaintained — author confirmed end-of-life on # 2026-06-07. Build-time only, pulled transitively via tabled_derive -> # tabled (springtale-cli table rendering). No runtime code, no CVE. Tracking diff --git a/Cargo.lock b/Cargo.lock index b656a475..33101141 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -811,16 +811,16 @@ dependencies = [ [[package]] name = "chitchat" -version = "0.10.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "735f8a51f68b353b17e351b38317433d6afcaa9cc04f4d0f6c9e9125c49c1efe" +checksum = "473eddded9fefd4df26abdf2aebd8b0c352fc5cb77fe12b4d3cf2f84fcc686bb" dependencies = [ "anyhow", "async-trait", "bytes", - "itertools 0.14.0", - "lru 0.13.0", - "rand 0.9.2", + "itertools 0.15.0", + "lru", + "rand 0.10.2", "serde", "tokio", "tokio-stream", @@ -2566,8 +2566,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -2588,6 +2586,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", "serde", "serde_core", @@ -3195,6 +3195,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3433,20 +3442,14 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru" version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "lru-cache" @@ -3699,7 +3702,7 @@ dependencies = [ "async-wsocket", "faster-hex", "futures", - "lru 0.18.4", + "lru", "negentropy", "nostr", "nostr-database", diff --git a/Cargo.toml b/Cargo.toml index 3f1643fe..482d9a6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -244,7 +244,7 @@ crossbeam-deque = "0.8" # ── Cooperation (Phase M) ───────────────────────────────────────────────────── dyn-clone = "1" # DynClone supertrait for Box -chitchat = "0.10" # §8 scuttlebutt gossip for awareness +chitchat = "0.13" # §8 scuttlebutt gossip for awareness foca = { version = "1.0", default-features = false, features = ["std", "tracing", "bincode-codec"] } # §8 SWIM liveness bincode = "2" # foca 1.0's BincodeCodec wire format (matches foca's internal dep) diff --git a/crates/springtale-cooperation/src/awareness/store/chitchat.rs b/crates/springtale-cooperation/src/awareness/store/chitchat.rs index c5261e2b..b94bd315 100644 --- a/crates/springtale-cooperation/src/awareness/store/chitchat.rs +++ b/crates/springtale-cooperation/src/awareness/store/chitchat.rs @@ -6,7 +6,7 @@ //! last_success, attention_load). Peers observe via `node_states()` and //! reconstruct `NeighborSnapshot`s. //! -//! See . +//! See . use std::collections::HashMap; use std::net::SocketAddr; @@ -16,7 +16,8 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use chitchat::transport::UdpTransport; use chitchat::{ - Chitchat, ChitchatConfig, ChitchatHandle, ChitchatId, FailureDetectorConfig, spawn_chitchat, + Chitchat, ChitchatConfig, ChitchatHandle, ChitchatId, FailureDetectorConfig, ProtocolVersion, + spawn_chitchat, }; use tokio::sync::Mutex; @@ -53,13 +54,18 @@ pub struct ChitchatGossipStore { impl ChitchatGossipStore { pub async fn spawn(cfg: ChitchatGossipConfig) -> Result { let chitchat_id = ChitchatId { - node_id: cfg.node_id, + // chitchat 0.13 interns node ids as `Arc`. + node_id: cfg.node_id.into(), generation_id: 0, gossip_advertise_addr: cfg.public_addr, }; let config = ChitchatConfig { chitchat_id, cluster_id: cfg.cluster_id, + // chitchat 0.13 made the digest wire format explicit. V0 is + // the crate's own default and the format 0.10 spoke, so the + // bump changes no bytes on the wire. + protocol_version: ProtocolVersion::V0, gossip_interval: cfg.gossip_interval, listen_addr: cfg.listen_addr, seed_nodes: cfg.seeds, @@ -182,7 +188,7 @@ impl GossipStore for ChitchatGossipStore { FIELD_SUCCESS, FIELD_ATTENTION, ] { - // chitchat 0.10: `delete` marks the key for GC after its TTL; + // chitchat 0.13: `delete` marks the key for GC after its TTL; // peers observe the absence on the next gossip round. state.delete(&Self::agent_key(agent_id, f)); } diff --git a/deny.toml b/deny.toml index 8caecee6..7a57838b 100644 --- a/deny.toml +++ b/deny.toml @@ -29,7 +29,6 @@ ignore = [ "RUSTSEC-2025-0119", # number_prefix unmaintained (cosmetic, CLI only) "RUSTSEC-2025-0134", # rustls-pemfile merged into rustls upstream "RUSTSEC-2025-0141", # bincode unmaintained (doxxing incident) — see vex/ - "RUSTSEC-2026-0002", # lru 0.13 IterMut Stacked Borrows via chitchat — see vex/ "RUSTSEC-2026-0097", # rand 0.8.5 log-feature unsoundness — log feat disabled "RUSTSEC-2026-0173", # proc-macro-error2 unmaintained (build-time, via tabled) — see vex/ ] diff --git a/vex/lru-rustsec-2026-0002.json b/vex/lru-rustsec-2026-0002.json deleted file mode 100644 index 430b1587..00000000 --- a/vex/lru-rustsec-2026-0002.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "@context": "https://openvex.dev/ns/v0.2.0", - "@id": "https://springtale.dev/vex/lru-rustsec-2026-0002.json", - "author": "Springtale Maintainers ", - "timestamp": "2026-05-13T00:00:00Z", - "version": 1, - "statements": [ - { - "vulnerability": { - "@id": "https://rustsec.org/advisories/RUSTSEC-2026-0002", - "name": "RUSTSEC-2026-0002", - "description": "lru 0.13.0 — `IterMut` violates Stacked Borrows by invalidating an internal pointer (Miri-detected unsoundness)" - }, - "products": [ - { - "@id": "pkg:cargo/lru@0.13.0", - "subcomponents": [ - { "@id": "pkg:cargo/chitchat@0.10.0" }, - { "@id": "pkg:cargo/springtale-cooperation@0.1.0" } - ] - } - ], - "status": "affected", - "justification": "vulnerable_code_not_present", - "impact_statement": "RUSTSEC-2026-0002 is a Stacked Borrows soundness finding affecting `lru::IterMut`. The advisory describes a Miri-detected aliasing violation; no in-the-wild exploitation pattern is documented. Springtale does not call `lru::IterMut` directly. The transitive consumer is `chitchat` (workspace dep, §8 scuttlebutt gossip), which uses `lru` as an internal cache. Even if chitchat's internal iteration triggers the soundness issue under specific compiler optimizations, the impact is bounded to a single gossip cache entry and cannot escalate beyond the local node — gossip messages remain HMAC-signed and authenticated separately.", - "action_statement": "Chitchat is on the `dependabot.yml` ignore list (wire-format critical) per `docs/security/SUPPLY-CHAIN.md`. We track chitchat upstream for an lru bump; will accept the bump after manual verification of the gossip wire format. No upgrade path within the chitchat 0.10.x line — would require chitchat 0.11+ which we have not yet vetted." - } - ] -} From 1c6a3ec09544d57c02d54caa6d894716779d1ff1 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 12:44:41 -0700 Subject: [PATCH 3/5] chore(types): commit ts-rs output as generated, exclude it from Biome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo test -p springtale-cooperation` re-runs the ts-rs exporters, which write tauri/packages/types/src/generated/*.ts in ts-rs's own style. What was committed there had been through Biome, so every Rust test run left the working tree dirty, and the reformat-vs-regenerate difference has been committed and reverted repeatedly. Fixed at the cause by making the committed bytes the generator's bytes: the directory joins the OpenAPI-generated `api.ts` in biome.json's files.includes exclusions, and the three drifted files are re-committed as ts-rs emits them. The alternative — shelling out to Biome from the export — was rejected: it would put node and a pnpm install on the critical path of `cargo test`, in a workspace whose Rust CI job has neither, to gain nothing but line breaks in files nobody reads by hand. `cargo test` now leaves `git status` clean, and `pnpm lint` passes (141 files checked). index.ts stays hand-written and says so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- tauri/biome.json | 3 ++- tauri/packages/types/src/generated/FormationDelta.ts | 4 +--- .../packages/types/src/generated/FormationOutcome.ts | 9 +-------- tauri/packages/types/src/generated/FormationView.ts | 11 +---------- tauri/packages/types/src/generated/index.ts | 7 +++++++ 5 files changed, 12 insertions(+), 22 deletions(-) diff --git a/tauri/biome.json b/tauri/biome.json index f4e09040..87bea318 100644 --- a/tauri/biome.json +++ b/tauri/biome.json @@ -15,7 +15,8 @@ "!**/pnpm-lock.yaml", "!**/*.css", "!packages/types/src/api.ts", - "!packages/types/openapi.json" + "!packages/types/openapi.json", + "!packages/types/src/generated" ] }, "formatter": { diff --git a/tauri/packages/types/src/generated/FormationDelta.ts b/tauri/packages/types/src/generated/FormationDelta.ts index bcbff711..a50fe1e7 100644 --- a/tauri/packages/types/src/generated/FormationDelta.ts +++ b/tauri/packages/types/src/generated/FormationDelta.ts @@ -7,6 +7,4 @@ import type { FormationView } from "./FormationView"; * snapshot stream and the terminal-outcome stream so subscribers don't * have to wire two channels. */ -export type FormationDelta = - | ({ kind: "view" } & FormationView) - | ({ kind: "outcome" } & FormationOutcome); +export type FormationDelta = { "kind": "view" } & FormationView | { "kind": "outcome" } & FormationOutcome; diff --git a/tauri/packages/types/src/generated/FormationOutcome.ts b/tauri/packages/types/src/generated/FormationOutcome.ts index 32dbc88b..6f0ddca0 100644 --- a/tauri/packages/types/src/generated/FormationOutcome.ts +++ b/tauri/packages/types/src/generated/FormationOutcome.ts @@ -5,11 +5,4 @@ * finished" awareness for sibling formations on the same connector * graph and feeds the global mental-model persistence layer (G2). */ -export type FormationOutcome = { - formation_id: string; - final_intent: string; - success_count: number; - failure_count: number; - dissolve_reason: string; - at: string; -}; +export type FormationOutcome = { formation_id: string, final_intent: string, success_count: number, failure_count: number, dissolve_reason: string, at: string, }; diff --git a/tauri/packages/types/src/generated/FormationView.ts b/tauri/packages/types/src/generated/FormationView.ts index 896ace8f..d82c5b9b 100644 --- a/tauri/packages/types/src/generated/FormationView.ts +++ b/tauri/packages/types/src/generated/FormationView.ts @@ -6,13 +6,4 @@ import type { FormationStatus } from "./FormationStatus"; * whenever the formation's intent changes, momentum tier flips, or * rally tokens cross a threshold. */ -export type FormationView = { - formation_id: string; - intent: string; - momentum_tier: string; - operational_count: number; - member_count: number; - rally_tokens_remaining: number; - status: FormationStatus; - at: string; -}; +export type FormationView = { formation_id: string, intent: string, momentum_tier: string, operational_count: number, member_count: number, rally_tokens_remaining: number, status: FormationStatus, at: string, }; diff --git a/tauri/packages/types/src/generated/index.ts b/tauri/packages/types/src/generated/index.ts index f32c2fda..3c2e14be 100644 --- a/tauri/packages/types/src/generated/index.ts +++ b/tauri/packages/types/src/generated/index.ts @@ -10,6 +10,13 @@ // on the next regeneration. To change a type's shape, change the Rust // source in `crates/springtale-cooperation/src/...` and re-run the // export. +// +// This directory is excluded from Biome (`biome.json` -> files.includes, +// same as the OpenAPI-generated `api.ts`). ts-rs emits its own style; +// running the formatter over it made every `cargo test` dirty the +// working tree, so what is committed here is the generator's raw output +// and nothing reformats it. This barrel file is the one hand-written +// file in the directory. export type { FormationDelta } from "./FormationDelta"; export type { FormationOutcome } from "./FormationOutcome"; From 0657ec3fe92e52bac2538c8fc5c11fba01d58bdf Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 12:47:41 -0700 Subject: [PATCH 4/5] docs: correct the Tauri 1 API references, rule on the CSP wildcard port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places still named `tauri::api::dialog`, a Tauri 1 path. The desktop is on Tauri 2 and depends on `tauri-plugin-dialog`; both ARCHITECTURE.md copies now say `tauri_plugin_dialog`, and the AUDIT-NOTES item that tracked the drift is closed. The CSP `connect-src http://127.0.0.1:*` stays, with the reason written down rather than left as an open item. The desktop frontend is a real HTTP client of the sidecar (`apps/desktop/src/provider.ts` points the API base at `http://127.0.0.1:${port}`), and the sidecar binds `127.0.0.1:0`, so the port is not known until unlock — after the CSP has already been baked into the build. Tauri offers no runtime CSP override, and CSP source expressions cannot express a port range, so the only way to name the port is a fixed port chosen at install, which trades a loopback wildcard for a collision. Findings 22, 83. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- docs/current-arch/ARCHITECTURE.md | 2 +- docs/current-arch/AUDIT-NOTES.md | 11 +++++++++-- docs/intended-arch/ARCHITECTURE.md | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/current-arch/ARCHITECTURE.md b/docs/current-arch/ARCHITECTURE.md index bb2dc837..b884409a 100644 --- a/docs/current-arch/ARCHITECTURE.md +++ b/docs/current-arch/ARCHITECTURE.md @@ -2452,7 +2452,7 @@ standalone in any browser — for headless/remote server management. | Deep link injection | Deep links (`springtale://`) parsed with strict schema validation. Only `connector-install` and `bot-pair` intents accepted. Malformed URLs rejected, not passed to handlers. | | WebView isolation | Tauri 2 runs WebView in separate process. WebView cannot access Rust memory directly — only through IPC commands. No `window.__TAURI__` global in production build. | | Canvas content injection | Canvas receives structured data (typed SolidJS stores), not raw HTML. No `innerHTML` or `dangerouslySetInnerHTML`. React-style XSS prevention via DOM API. | -| Approval modal spoofing | Capability approval and destructive action modals are native Tauri dialogs (`tauri::api::dialog`), not WebView DOM elements. Cannot be spoofed by frontend JavaScript. | +| Approval modal spoofing | Capability approval and destructive action modals are native Tauri dialogs (`tauri_plugin_dialog`), not WebView DOM elements. Cannot be spoofed by frontend JavaScript. | | Mobile: biometric bypass | Biometric auth failure falls back to vault passphrase, never to "no auth". Failed biometric attempts rate-limited by OS. | **Privacy audit for Tauri shell:** diff --git a/docs/current-arch/AUDIT-NOTES.md b/docs/current-arch/AUDIT-NOTES.md index 4e50b49e..3f874632 100644 --- a/docs/current-arch/AUDIT-NOTES.md +++ b/docs/current-arch/AUDIT-NOTES.md @@ -211,8 +211,15 @@ The OpenClaw migration path (§1.3 in SECURITY.md) is ambitious but vague: - Sentinel initialized at Phase 1 startup but documented as Phase 2a - Event loop uses ? propagation (kills bot on single message failure) - Fuel division formula (parent/4) undefined for varying child counts -- Tauri CSP wildcard port on 127.0.0.1 too broad -- Tauri API references are Tauri 1 style (tauri::api::dialog → tauri_plugin_dialog) +- Tauri CSP wildcard port on 127.0.0.1: ruled and kept. The desktop + webview talks to the `springtaled` sidecar over loopback HTTP at a port + the OS assigns when the vault unlocks (`sidecar.rs` binds + `127.0.0.1:0`), and Tauri bakes `csp` into the app at build time with no + runtime override. Naming the port would mean the sidecar binding a + fixed port chosen at install — trading a loopback wildcard for a + collision-prone fixed port — and CSP source expressions have no + port-range syntax, so there is no narrower spelling. Revisit only if + the sidecar gains a fixed port. - rustup in Nix shell is anti-pattern (should use fenix/rust-overlay) - Missing WIT interface definition for TypeScript SDK diff --git a/docs/intended-arch/ARCHITECTURE.md b/docs/intended-arch/ARCHITECTURE.md index 0b7f209f..eec0687b 100644 --- a/docs/intended-arch/ARCHITECTURE.md +++ b/docs/intended-arch/ARCHITECTURE.md @@ -2261,7 +2261,7 @@ standalone in any browser — for headless/remote server management. | Deep link injection | Deep links (`springtale://`) parsed with strict schema validation. Only `connector-install` and `bot-pair` intents accepted. Malformed URLs rejected, not passed to handlers. | | WebView isolation | Tauri 2 runs WebView in separate process. WebView cannot access Rust memory directly — only through IPC commands. No `window.__TAURI__` global in production build. | | Canvas content injection | Canvas receives structured data (typed SolidJS stores), not raw HTML. No `innerHTML` or `dangerouslySetInnerHTML`. React-style XSS prevention via DOM API. | -| Approval modal spoofing | Capability approval and destructive action modals are native Tauri dialogs (`tauri::api::dialog`), not WebView DOM elements. Cannot be spoofed by frontend JavaScript. | +| Approval modal spoofing | Capability approval and destructive action modals are native Tauri dialogs (`tauri_plugin_dialog`), not WebView DOM elements. Cannot be spoofed by frontend JavaScript. | | Mobile: biometric bypass | Biometric auth failure falls back to vault passphrase, never to "no auth". Failed biometric attempts rate-limited by OS. | **Privacy audit for Tauri shell:** From dd096d377d04bc807e7d4c7e38f8eab4bf045ba3 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 12:48:45 -0700 Subject: [PATCH 5/5] docs: drop the two CLI verbs that were never built; unpin the recipe count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `springtale-cli vault unset` and `springtale-cli connector setup` appear in the tutorials but in no command tree: VaultAction has one variant (DuressSetup) and ConnectorAction has list/enable/disable/remove/install/ sign. Tutorial 01's cleanup step is removed — `connector remove` on the line above it is the real cleanup, and the next line already revokes the token at BotFather. Tutorial 02 now points at the connector setup flow it already described two paragraphs later (the dashboard card or the setup API) instead of a CLI verb. Still unfixed and out of this scope: `springtale-cli new