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-bot/src/conversation/catalog/snapshot.rs b/crates/springtale-bot/src/conversation/catalog/snapshot.rs index 8490e5d3..9802d646 100644 --- a/crates/springtale-bot/src/conversation/catalog/snapshot.rs +++ b/crates/springtale-bot/src/conversation/catalog/snapshot.rs @@ -1,7 +1,10 @@ //! Catalogue snapshot — the recipe library projected into the shape //! the NLU engine scores and slot-fills against. //! -//! Built fresh each turn from `list_recipes` (≈60 built-ins; cheap). +//! Built fresh each turn from `list_recipes` — the whole builtin +//! catalogue plus the user's own recipes; cheap. No count is written +//! down here: the catalogue is whatever `list_recipes` returns, and +//! every hardcoded figure this comment has carried has gone stale. //! Every recipe becomes an [`IntentDoc`]: stemmed name/tag/description //! token bags for intent scoring, plus a [`SlotSpec`] per input field //! carrying a precomputed [`Gazetteer`] for `Select` fields so the 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-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/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" })); 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/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:** diff --git a/docs/tutorials/01-moderation-bot.md b/docs/tutorials/01-moderation-bot.md index 84c6af6e..ba3d684e 100644 --- a/docs/tutorials/01-moderation-bot.md +++ b/docs/tutorials/01-moderation-bot.md @@ -298,7 +298,6 @@ springtale-cli rule delete flag-slurs springtale-cli rule delete escalate-on-second-offence springtale-cli rule delete welcome springtale-cli connector remove connector-telegram -springtale-cli vault unset telegram.bot_token ``` Delete the bot via BotFather (`/deletebot`). diff --git a/docs/tutorials/02-cross-platform-alerts.md b/docs/tutorials/02-cross-platform-alerts.md index 6260e998..4f425d20 100644 --- a/docs/tutorials/02-cross-platform-alerts.md +++ b/docs/tutorials/02-cross-platform-alerts.md @@ -27,17 +27,10 @@ parallel actions. No agents, no cooperation, just a wide rule. ## Step 1 — Set up the connectors -We need four connectors. Set their credentials in the vault: - -```bash -# Kick: OAuth 2.1 PKCE flow — the CLI walks you through it. -springtale-cli connector setup kick - -``` - -The other three take their credentials through the connector setup -flow — each connector's card in the dashboard, or the connector setup -API: +We need four connectors. All four take their credentials through the +connector setup flow — each connector's card in the dashboard, or the +connector setup API. Kick's is an OAuth 2.1 PKCE flow the card walks you +through; the other three want a token you already hold: - Bluesky: ATProto session — handle (`your.handle.bsky.social`) plus an app password from https://bsky.app/settings/app-passwords 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"; 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." - } - ] -}