diff --git a/.claude/rules/backend/connector-guidelines.md b/.claude/rules/backend/connector-guidelines.md index 53515f80..9568f990 100644 --- a/.claude/rules/backend/connector-guidelines.md +++ b/.claude/rules/backend/connector-guidelines.md @@ -55,9 +55,51 @@ Every connector implements `springtale_connector::Connector`: ## Manifest -Every connector ships with a `connector-{name}.toml` manifest declaring: +Every connector returns a `ConnectorManifest` from its `fn manifest()`. +It is **built in code, not parsed from a file** — there is no +`connector-{name}.toml`. Building it in code keeps the declaration compiled +and type-checked beside the `triggers()` and `actions()` it describes, so a +declaration cannot silently drift from the thing it declares. + +`ConnectorManifest` (`crates/springtale-connector/src/manifest/types.rs`) +carries: + - Name, version, author, description -- Required capabilities (NetworkOutbound hosts, FilesystemRead paths, etc.) -- Trigger declarations with typed schemas -- Action declarations with typed input/output schemas -- DataDisclosure: what user data the connector accesses +- `capabilities: Vec` — `NetworkOutbound { host }` (exact host, + no wildcards), `FilesystemRead`/`FilesystemWrite { path }`, + `KeychainRead { key }`, `ShellExec` (blocking approval, never bypassable) +- `triggers: Vec` — name, description, optional JSON Schema of + the event payload +- `actions: Vec` — name, description, optional input/output JSON + Schema, plus the three hints below +- `data_disclosure: Vec` — what user data is touched, why, + and where it is sent +- `roles: Vec` — custom cooperation roles contributed to the shared + `RoleRegistry` at install time +- `wasm_hash` — SHA-256 of the `.wasm` binary, WASM connectors only +- `signature_alg` + `signature` — signature over the canonical JSON of every + other field, verified before load. The manifest is serialized for signing + and to travel with a WASM binary; that is the only reason it derives + `Serialize`/`Deserialize`, not because anyone authors it as config. + +### `ActionDecl` hints + +All three carry MCP tool-annotation semantics and are **advisory only**. The +deterministic security boundary stays in `springtale-sentinel` and the +capability layer; never treat a hint as a gate. + +- `read_only: bool` — MCP `readOnlyHint`. See rule 8 above. Default `false` + (assume the action mutates). +- `destructive: Option` — MCP `destructiveHint`: whether the action + performs a *destructive* update rather than an additive one. `None` means + unknown and classifies as destructive, matching MCP's default of `true`. + Meaningful only when `read_only == false`. Set it explicitly to `Some(false)` + for a plain additive write (posting a new message) rather than leaving it + `None` and having it treated as a delete. +- `poll_interval_secs: Option` — opt-in sensing cadence, in seconds. + `None` (the default) means a formation never polls the action: work comes + from the environment — triggers, handoffs, CFP awards, surfaces — not from + a central poller. `Some(n)` permits polling on that interval, floored to + 5 s (Home Assistant's `update_interval` floor), and only if the action is + also `read_only` and requires no parameters. Set it only for an action that + is genuinely cheap and genuinely a sensor. diff --git a/.claude/rules/backend/security.md b/.claude/rules/backend/security.md index 0169da42..7d7b19dd 100644 --- a/.claude/rules/backend/security.md +++ b/.claude/rules/backend/security.md @@ -44,6 +44,9 @@ paths: ## Network - Management API binds `127.0.0.1` by default. Warn on `0.0.0.0`. -- HMAC bearer tokens for API auth. +- API bearers are issued, never derived: `POST /auth/login` mints a random session token, + `POST /auth/tokens` mints named long-lived ones. +- Both stored only as `sha256(token)`, compared constant-time via `subtle`. +- The passphrase-derived hash is the login verifier only. Never accepted as a bearer. - Rate limiting via `tower-http::limit`. - No secrets in URLs, query params, or error messages. diff --git a/Cargo.toml b/Cargo.toml index 482d9a6d..37209d0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,13 +41,21 @@ members = [ "connectors/connector-slack", "connectors/connector-signal", "connectors/connector-browser", - # connector-matrix: DEFERRED — upstream matrix-sdk-sqlite (v0.18.0 main) - # still declares rusqlite = "0.37.0" which carries CVE-2025-70873 (heap - # info disclosure). Our store crate is pinned to rusqlite 0.39 (patched); + # connector-matrix: NOT IN THE TREE. There is no + # `connectors/connector-matrix` directory — no crate, no src/, nothing to + # re-enable by uncommenting a line here. + # + # Why it was never landed: upstream matrix-sdk-sqlite (v0.18.0 main) still + # declares rusqlite = "0.37.0", which carries CVE-2025-70873 (heap info + # disclosure). Our store crate is pinned to rusqlite 0.39 (patched), and # downgrading would expose activist/survivor data to the heap leak. # Accountable tracking: `vex/connector-matrix-rusqlite-cve-2025-70873.json` - # (v2, last reviewed 2026-06-02, next_review 2026-09-01). The src/ tree - # is preserved so it compiles when upstream lands a rusqlite >= 0.39 bump. + # (v2, last reviewed 2026-06-02, next_review 2026-09-01). + # + # To bring Matrix support back, two things must happen, in order: + # 1. Upstream matrix-sdk-sqlite bumps to rusqlite >= 0.39. + # 2. Someone writes `connectors/connector-matrix` from scratch against + # the current `Connector` trait, then adds it to `members` above. # Apps "apps/springtaled", "apps/springtale-cli", diff --git a/apps/springtale-cli/examples/task-runner.rs b/apps/springtale-cli/examples/task-runner.rs index 40351983..abd094bc 100644 --- a/apps/springtale-cli/examples/task-runner.rs +++ b/apps/springtale-cli/examples/task-runner.rs @@ -134,6 +134,7 @@ async fn main() -> Result<(), Box> { latency: Duration::from_millis(5), intent_alignment: 1.0, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, }; let _ = reports_tx.send(report).await; } diff --git a/apps/springtaled/src/api/lock.rs b/apps/springtaled/src/api/lock.rs index 7f95a6fb..7167670a 100644 --- a/apps/springtaled/src/api/lock.rs +++ b/apps/springtaled/src/api/lock.rs @@ -519,11 +519,14 @@ where /// POST /vault/unlock — public, rate-limited. /// -/// Deliberately unauthenticated: the API token is derived from the -/// passphrase, so there is no credential to present while locked. The -/// passphrase itself is the credential, and `Vault::open` is the check -/// — Argon2id over the wrong passphrase fails at AEAD decryption, with -/// no comparison this code could shortcut. +/// Deliberately unauthenticated: while the vault is locked there is no +/// bearer that could be presented. Bearers are *issued*, never derived +/// from the passphrase (plan 6.6) — a session comes from +/// `POST /auth/login` and lives in the process state that locking drops, +/// and a long-lived token can only be looked up against that same +/// dropped state. So the passphrase itself is the credential here, and +/// `Vault::open` is the check — Argon2id over the wrong passphrase fails +/// at AEAD decryption, with no comparison this code could shortcut. async fn unlock(State(guard): State, Json(body): Json) -> Response { if !guard.is_locked() { return ( diff --git a/apps/springtaled/src/api/mod.rs b/apps/springtaled/src/api/mod.rs index 07e168b7..888103e7 100644 --- a/apps/springtaled/src/api/mod.rs +++ b/apps/springtaled/src/api/mod.rs @@ -201,6 +201,11 @@ pub fn build_router(state: AppState) -> Router { ) .route("/workspaces/scan", post(workspaces::scan)) .route("/workspaces/onboard-url", post(workspaces::onboard_url)) + // SSE response, but a bearer-authenticated POST like every + // other mutating route: it carries its connector config in a + // JSON body, so `EventSource` (GET-only, no headers) was never + // able to call it and it never needed a stream ticket. + .route("/workspaces/onboard", post(workspaces::onboard)) .route("/sessions", get(sessions::list)) .route( "/config/heartbeat", @@ -378,11 +383,14 @@ pub fn build_router(state: AppState) -> Router { // in the query string instead of a bearer token. Read-only GETs, so // no CSRF layer. `/stream` multiplexes events/canvas/cooperation; // `/chat/stream` stays separate because it is per-session. + // + // Nothing that mutates state belongs here: a ticket is single-use, + // minted for a stream, and skips the CSRF/Origin layer. A streaming + // *response* is not a reason to move a route in — see + // `/workspaces/onboard`, which streams from the authenticated router. let streams = Router::new() .route("/stream", get(stream::stream)) .route("/chat/stream", get(chat::stream)) - // POST: the connector config rides in the body, never the URL. - .route("/workspaces/onboard", post(workspaces::onboard)) .layer(middleware::from_fn_with_state( state.clone(), auth::require_stream_ticket, diff --git a/apps/springtaled/src/api/workspaces.rs b/apps/springtaled/src/api/workspaces.rs index 0cc4a5c7..32cb9ecb 100644 --- a/apps/springtaled/src/api/workspaces.rs +++ b/apps/springtaled/src/api/workspaces.rs @@ -1,7 +1,8 @@ //! HTTP routes for the D1 external-workspace directory and the //! Track D one-click Onboard stream — the same //! `operations::workspaces` calls the desktop IPC commands make -//! (plan 2.5). `onboard` is SSE under the stream-ticket layer. +//! (plan 2.5). `onboard` answers with SSE but is a bearer-authenticated +//! POST like every other mutating route. use std::convert::Infallible; use std::sync::Arc; @@ -191,9 +192,17 @@ impl Drop for CancelOnDrop { } } -/// POST /workspaces/onboard?ticket=.. — SSE of `chat-discovered` -/// frames (same payload as the desktop `ChatDiscovered` event) until -/// the first match, the 60 s window, or client disconnect. +/// POST /workspaces/onboard — SSE of `chat-discovered` frames (same +/// payload as the desktop `ChatDiscovered` event) until the first +/// match, the 60 s window, or client disconnect. +/// +/// This mutates (it deploys a probe through the connector), so it sits +/// in the bearer + CSRF `authenticated` router, not in the stream-ticket +/// router. The ticket exists for `EventSource`, which cannot send an +/// `Authorization` header — but `EventSource` only issues GETs and this +/// route needs its connector config in a POST body, so its client was +/// always `fetch`, which can send the header. Streaming the response is +/// unaffected: axum's `Sse` does not care how the request authenticated. #[utoipa::path( post, operation_id = "workspaces_onboard", path = "/workspaces/onboard", diff --git a/apps/springtaled/tests/api_integration.rs b/apps/springtaled/tests/api_integration.rs index 3543c7f9..01a808ac 100644 --- a/apps/springtaled/tests/api_integration.rs +++ b/apps/springtaled/tests/api_integration.rs @@ -630,6 +630,53 @@ async fn test_stream_bearer_in_query_returns_401() { assert_eq!(status, StatusCode::UNAUTHORIZED); } +/// `POST /workspaces/onboard` streams SSE but mutates (it deploys a +/// probe through the connector), so it must carry the same bearer as +/// every other mutating route — not a single-use stream ticket. +#[tokio::test] +async fn test_workspaces_onboard_requires_bearer() { + let body = serde_json::json!({ + "connector_name": "connector-telegram", + "config": {}, + }) + .to_string(); + + // No credential at all. + let (router, _token) = build_test_app(true); + let req = Request::post("/workspaces/onboard") + .header("content-type", "application/json") + .body(Body::from(body.clone())) + .unwrap(); + let (status, _) = send(router, req).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "onboard must reject an unauthenticated request" + ); + + // A stream ticket is not a credential for a mutating route: it is + // minted for a stream and skips the CSRF/Origin layer. + let (router, token) = build_test_app(true); + let ticket_req = Request::post("/stream/ticket") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let (ticket_status, ticket_json) = send(router.clone(), ticket_req).await; + assert_eq!(ticket_status, StatusCode::OK); + let ticket = ticket_json["ticket"].as_str().unwrap().to_owned(); + + let req = Request::post(format!("/workspaces/onboard?ticket={ticket}")) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(); + let (status, _) = send(router, req).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "a stream ticket must not authenticate onboard" + ); +} + #[tokio::test] async fn test_stream_ticket_requires_bearer() { let (router, _token) = build_test_app(true); diff --git a/apps/springtaled/tests/event_recipe_e2e.rs b/apps/springtaled/tests/event_recipe_e2e.rs index 7642a553..f115cd1b 100644 --- a/apps/springtaled/tests/event_recipe_e2e.rs +++ b/apps/springtaled/tests/event_recipe_e2e.rs @@ -27,7 +27,23 @@ use springtale_runtime::CapabilityBridge; use springtale_runtime::operations::recipes::apply::substitute_template_public; use springtale_runtime::operations::recipes::builtin; use springtale_runtime::operations::recipes::types::{FieldKind, RecipeInputs}; -use springtale_sentinel::{AutoAllowApprovalGate, Sentinel, SentinelConfig}; +use springtale_sentinel::{ApprovalGate, ApprovalRequest, Sentinel, SentinelConfig}; + +/// Local auto-allow gate for this e2e harness. +/// +/// `springtale_sentinel::approval::AutoAllowApprovalGate` is +/// `#[cfg(test)]`-only inside its own crate so a production build cannot +/// disable the human approval gate. Tests that genuinely want the gate +/// out of the path declare their own, as here — the exemption stays +/// visible in the test that takes it. +struct AutoAllowApprovalGate; + +#[async_trait::async_trait] +impl ApprovalGate for AutoAllowApprovalGate { + async fn request_approval(&self, _request: ApprovalRequest) -> bool { + true + } +} use springtale_store::SqliteBackend; use tokio::sync::RwLock; diff --git a/crates/springtale-bot/src/cooperation/blackboard_router.rs b/crates/springtale-bot/src/cooperation/blackboard_router.rs index 4db81271..d4c826d2 100644 --- a/crates/springtale-bot/src/cooperation/blackboard_router.rs +++ b/crates/springtale-bot/src/cooperation/blackboard_router.rs @@ -326,6 +326,7 @@ mod tests { latency: std::time::Duration::from_millis(0), intent_alignment: 0.95, interference_with: vec![], + state: springtale_cooperation::action_state::ActionState::Success, }]); let pick = router diff --git a/crates/springtale-bot/src/cooperation/formation.rs b/crates/springtale-bot/src/cooperation/formation.rs index 9c3ec53d..e08079ba 100644 --- a/crates/springtale-bot/src/cooperation/formation.rs +++ b/crates/springtale-bot/src/cooperation/formation.rs @@ -338,6 +338,10 @@ pub struct Formation { /// `Stabilize` intent change or `ForcedDissolve` per /// `crates/springtale-bot/src/orchestrator/intervention/evaluator/rules.rs`. pub cascade_hit_streak: u32, + /// Members a rally token has been spent on, still owing a beat that + /// shows they came back. `rally::cascade::recovered` reads this to + /// raise `RallyResult::Recovered`; cleared when one of them does. + pub rallied: std::collections::HashSet, /// Set by `tick_steps/supervision.rs` when the supervisor returns /// `SupervisionAction::Escalate`. Read by `check_interventions.rs` /// next tick and folded into the intervention signals; cleared after @@ -535,6 +539,7 @@ impl Formation { last_tick_write_count: 0, last_broadcast_tier: MomentumTier::Cold, cascade_hit_streak: 0, + rallied: std::collections::HashSet::new(), escalation_pending: None, cfp_channels, cfp_initiator, diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs index 080f1705..d19cfbad 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs @@ -191,5 +191,11 @@ pub fn post_member( latency: Duration::from_millis(outcome.duration_ms), intent_alignment: outcome.alignment, interference_with: vec![], + // 0.3 — the beat's momentum signal. `Requested` (a dispatch + // carried past its beat) and `Init` (a claim, an observe/suggest + // surface reaction, a yield) are not work done, whatever their + // alignment; only `Success`/`Failure` say the beat finished + // something. + state: outcome.state, } } diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/prepare.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/prepare.rs index da3ec368..022f82eb 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/prepare.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/prepare.rs @@ -60,10 +60,19 @@ pub struct ExecuteCtx<'a> { } impl ExecuteCtx<'_> { - /// Manifest-declared hints for the task's action, looked up the same - /// way dispatch resolves the connector. `None` means the connector is + /// Declared hints for the task's action, looked up the same way + /// dispatch resolves the connector. `None` means the connector is /// not installed or does not declare the action — the caller treats /// an unknown action as destructive. + /// + /// AUTHORITATIVE SOURCE: `ConnectorHost::actions()`. It is what + /// `springtale_runtime::dispatch::step` reads when it builds the + /// `ActionHints` the sentinel classifies with, so the consensus vote + /// here and the sentinel verdict there must read the same list. This + /// used to read `manifest().actions` instead; a host whose two lists + /// disagree would have let a task be voted through as read-only and + /// then dispatched as a mutation (or the reverse). Any new hint + /// consumer reads `actions()` too. pub async fn action_hints_for( &self, task: &SubTask, @@ -72,8 +81,7 @@ impl ExecuteCtx<'_> { let entry = registry.get(&task.target_connector.name)?; entry .host - .manifest() - .actions + .actions() .iter() .find(|decl| decl.name == task.action_name) .map(|decl| springtale_sentinel::ActionHints { diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs index cf044d89..a8fc45ab 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs @@ -181,6 +181,7 @@ pub(crate) fn successful_tick_result(agent: AgentId) -> FormationTickResult { latency: Duration::from_millis(1), intent_alignment: 1.0, interference_with: vec![], + state: springtale_cooperation::action_state::ActionState::Success, }], interferences: vec![], all_succeeded: true, diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs index 18a4a5dd..42b5832b 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs @@ -55,6 +55,7 @@ mod tests { latency: Duration::from_millis(1), intent_alignment: 0.9, interference_with: vec![], + state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs b/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs index 6de2b769..a9c692d8 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs @@ -29,6 +29,20 @@ pub async fn run( store: &dyn StorageBackend, cooperation_tx: Option<&broadcast::Sender>, ) { + // A member a token was spent on finished work this beat: the rally + // worked. This is the only place `RallyResult::Recovered` is raised — + // it is a later beat's answer to an earlier rally, not an outcome + // `attempt_self_rally` can return. + if let Some(recovery) = cascade::recovered(&formation.rallied, result) { + log_rally_result(&formation.id.0.to_string(), &recovery); + formation.rallied.clear(); + springtale_cooperation::utterance::utter( + &mut formation.utter_ctx(cooperation_tx), + None, + springtale_cooperation::UtteranceKind::Rally, + ); + } + if result.all_succeeded { // Successful tick clears the cascade streak so the L6 evaluator // (`check_interventions.rs`) doesn't trip on a long-resolved @@ -37,6 +51,9 @@ pub async fn run( return; } + // Only operational members: `is_operational` is false for + // `Incapacitated` and `Dead`, and a member that cannot respond must + // never be handed a rally token. let awareness_map: HashMap = formation .members .iter() @@ -48,6 +65,21 @@ pub async fn run( return; }; + // Total War (§15.2): the rally goes to the member who can still + // answer it — the lowest morale still above the shattered floor — not + // to whichever failing report happened to come first. A shattered, + // incapacitated or dead member gets no token: `select_rally_target` + // returns `None` and we spend nothing. Resolved here so the awareness + // borrow ends before the formation is written to below. + let failing: Vec = result + .reports + .iter() + .filter(|r| r.intent_alignment <= 0.5) + .map(|r| r.agent_id) + .collect(); + let target = cascade::select_rally_target(&awareness_map, &failing); + drop(awareness_map); + // Increment the streak — this is the cascade_hits signal the L6 // intervention evaluator reads next. Saturating add so a perpetually // unhealthy formation never wraps. @@ -74,23 +106,21 @@ pub async fn run( }, ); - let Some(failing_agent) = result - .reports - .iter() - .find(|r| r.intent_alignment <= 0.5) - .map(|r| r.agent_id) - else { + let Some(failing_agent) = target else { + tracing::debug!( + formation = %formation.id.0, + failing = failing.len(), + "cascade detected but no member can be rallied — token withheld" + ); return; }; - let rally_result = cascade::attempt_self_rally( - &formation.rally, - &formation.attention_broker, - &mut formation.momentum, - failing_agent, - ); + let rally_result = + cascade::attempt_self_rally(&formation.rally, &formation.attention_broker, failing_agent); log_rally_result(&formation.id.0.to_string(), &rally_result); if matches!(rally_result, RallyResult::StabilizedWithCost { .. }) { + // Owed a beat that shows the member came back (fix 3). + formation.rallied.insert(failing_agent); springtale_cooperation::utterance::utter( &mut formation.utter_ctx(cooperation_tx), None, diff --git a/crates/springtale-bot/src/runtime/tick_steps/gossip_awareness.rs b/crates/springtale-bot/src/runtime/tick_steps/gossip_awareness.rs index 972bb72a..23367a35 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/gossip_awareness.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/gossip_awareness.rs @@ -56,7 +56,12 @@ pub async fn run(formation: &mut Formation, result: &FormationTickResult) { for m in formation.members.iter_mut() { for snap in &snapshots { if snap.agent_id != m.agent_id { - m.awareness.update_neighbor(snap.clone()); + // Merge, not replace: the L2 react step folded what this + // member HEARD peers say earlier in this same beat, and + // gossip reports `last_action_success: true` for any peer + // that filed no report — which used to overwrite the fold + // before anything could act on it. + m.awareness.merge_neighbor(snap.clone()); } } m.awareness.formation_momentum = tier; diff --git a/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs b/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs index cecfaf98..403bf8fb 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs @@ -383,7 +383,6 @@ pub async fn handle_formation_command(bot: &mut Bot, cmd: FormationCommand) { let rally_result = cascade::attempt_self_rally( &formation.rally, &formation.attention_broker, - &mut formation.momentum, agent, ); match &rally_result { diff --git a/crates/springtale-bot/src/runtime/tick_steps/supervision.rs b/crates/springtale-bot/src/runtime/tick_steps/supervision.rs index 50f529ac..2c4ba7c6 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/supervision.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/supervision.rs @@ -86,12 +86,8 @@ fn execute( ); } SupervisionAction::RetryWithRally { agent } => { - let result = cascade::attempt_self_rally( - &formation.rally, - &formation.attention_broker, - &mut formation.momentum, - agent, - ); + let result = + cascade::attempt_self_rally(&formation.rally, &formation.attention_broker, agent); tracing::info!( formation = formation_id, agent = %agent.0, diff --git a/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs b/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs index bcba007f..82b793e5 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs @@ -3,9 +3,9 @@ //! Each tick is classified into exactly one `MomentumEvent` (see //! [`classify`]): //! * `TickInterference` — interference was detected (§13). -//! * `TickFailure` — a member acted and misaligned (alignment <= 0.5). -//! * `TickSuccess` — at least one member acted and nothing failed. -//! * `TickIdle` — nobody acted. Not a success, not a failure. Per the +//! * `TickFailure` — a member finished work that failed or misaligned. +//! * `TickSuccess` — at least one member finished work and nothing failed. +//! * `TickIdle` — nobody finished work. Not a success, not a failure. Per the //! Microsoft AGT trust calibration, idle time cannot raise scores; //! only the decay clock keeps running. //! @@ -16,6 +16,8 @@ //! (§14) executed in `transformation::run`. use crate::cooperation::formation::Formation; +use springtale_cooperation::action_state::ActionState; +use springtale_cooperation::cadence::TickReport; use springtale_cooperation::momentum::{MomentumEvent, TickCounts}; use springtale_cooperation::tick_processor::FormationTickResult; use springtale_cooperation::utterance::{UtteranceKind, utter}; @@ -24,13 +26,32 @@ use springtale_cooperation::utterance::{UtteranceKind, utter}; pub const LISTENING_AFTER_TICKS: u32 = 5; use std::collections::HashSet; +/// Whether this report is work the beat actually finished. +/// +/// The report's [`ActionState`] is the source, not `action_taken` and not +/// `intent_alignment`. Several non-work paths surface a descriptor with a +/// high alignment — a dispatch carried past its beat reports `Requested` +/// at 0.8, a continued active task reports 1.0 while the task is merely +/// claimed, a sacrifice yield reports 0.9, and the observe, suggest and +/// no-task paths report the step's surface reaction at 1.0. None of those +/// finished anything, so none of them may move momentum: a hung connector +/// call or an observe-autonomy member must not walk a formation to Fever. +fn completed_work(report: &TickReport) -> bool { + report.state.is_terminal() +} + +/// Whether the finished work counted as a success: the action reached +/// `Success` *and* aligned with the formation's intent. +fn succeeded(report: &TickReport) -> bool { + matches!(report.state, ActionState::Success) && report.intent_alignment > 0.5 +} + /// Classify a tick result into the single `MomentumEvent` it represents. /// -/// A report with `action_taken: None` is idle regardless of its alignment -/// (the executor reports alignment 1.0 for "nothing to do", which is not -/// a success). Only reports that actually acted can succeed or fail. -/// Success and failure carry the tick's [`TickCounts`] for the momentum -/// window. +/// A report that did not reach a terminal action state is idle regardless +/// of its alignment — waiting and claimed-only are not success. Only +/// reports that finished work can succeed or fail. Success and failure +/// carry the tick's [`TickCounts`] for the momentum window. pub fn classify(result: &FormationTickResult) -> MomentumEvent { let counts = count(result); let failed = counts.successes < counts.actions; @@ -60,13 +81,16 @@ fn count(result: &FormationTickResult) -> TickCounts { let mut seen: HashSet<(&str, Option<&str>, u64)> = HashSet::new(); let mut counts = TickCounts::default(); for report in &result.reports { - let Some(action) = report.action_taken.as_ref() else { + if !completed_work(report) { continue; - }; + } counts.actions = counts.actions.saturating_add(1); - if report.intent_alignment > 0.5 { + if succeeded(report) { counts.successes = counts.successes.saturating_add(1); } + let Some(action) = report.action_taken.as_ref() else { + continue; + }; let key = ( action.kind.as_str(), action.target.as_deref(), @@ -91,8 +115,9 @@ pub fn run( formation.momentum.apply_event(&classify(result)); // Step 4b — per-member consecutive failures for role transformation - // (§14). Idle and aligned reports reset the counter; a member that - // acted and misaligned increments it. + // (§14). Idle reports and finished-and-aligned work reset the counter; + // only a member whose work finished badly increments it. A member + // still waiting on a dispatch is neither. for report in &result.reports { let mut now_listening = false; if let Some(member) = formation.member_mut(&report.agent_id) { @@ -102,7 +127,7 @@ pub fn run( } else { member.consecutive_idle_ticks = 0; } - if report.action_taken.is_none() || report.intent_alignment > 0.5 { + if !completed_work(report) || succeeded(report) { member.consecutive_failures = 0; } else { member.consecutive_failures += 1; @@ -121,11 +146,13 @@ pub fn run( #[cfg(test)] mod tests { use super::*; + use crate::cooperation::dispatch_outcome::REQUESTED_ALIGNMENT; use springtale_cooperation::cadence::{ActionDescriptor, AgentId, TickReport}; + use springtale_cooperation::momentum::{MomentumState, MomentumTier}; use springtale_cooperation::tick::TickId; use std::time::Duration; - fn report(action: Option<&str>, alignment: f32) -> TickReport { + fn stated(action: Option<&str>, alignment: f32, state: ActionState) -> TickReport { TickReport { agent_id: AgentId::new(), tick_sequence: TickId(1), @@ -137,9 +164,19 @@ mod tests { latency: Duration::from_millis(1), intent_alignment: alignment, interference_with: vec![], + state, } } + /// A report for work that finished this beat (or for an idle member). + fn report(action: Option<&str>, alignment: f32) -> TickReport { + let state = match action { + Some(_) => ActionState::Success, + None => ActionState::Init, + }; + stated(action, alignment, state) + } + fn tick(reports: Vec) -> FormationTickResult { FormationTickResult { reports, @@ -190,4 +227,38 @@ mod tests { MomentumEvent::TickFailure { counts } if counts.actions == 2 && counts.successes == 1 )); } + + /// Fix 1 — a hung dispatch does not promote. + /// + /// A connector call carried past its beat reports `Requested` with a + /// descriptor and alignment 0.8. Under the old alignment-only rule + /// that was a success every beat, so a formation whose members were + /// all stuck walked itself to Fever. It is idle, and idle never + /// promotes. + #[test] + fn test_hung_dispatch_is_idle_and_never_promotes() { + let hung = || stated(Some("work"), REQUESTED_ALIGNMENT, ActionState::Requested); + let result = tick(vec![hung(), hung()]); + assert!(matches!(classify(&result), MomentumEvent::TickIdle)); + + let mut momentum = MomentumState::default(); + for _ in 0..50 { + momentum.apply_event(&classify(&result)); + } + assert_eq!(momentum.tier, MomentumTier::Cold); + assert_eq!(momentum.consecutive_successes, 0); + } + + /// A claim, an observe-autonomy surface reaction and a sacrifice + /// yield all report a descriptor at high alignment without finishing + /// anything. None of them is a success. + #[test] + fn test_claimed_and_observed_reports_are_idle() { + let result = tick(vec![ + stated(Some("claimed"), 1.0, ActionState::Init), + stated(Some("sacrifice_yield"), 0.9, ActionState::Init), + stated(Some("cancelled"), 1.0, ActionState::Cancelled), + ]); + assert!(matches!(classify(&result), MomentumEvent::TickIdle)); + } } diff --git a/crates/springtale-connector/src/manifest/types.rs b/crates/springtale-connector/src/manifest/types.rs index b6897b41..9908f65f 100644 --- a/crates/springtale-connector/src/manifest/types.rs +++ b/crates/springtale-connector/src/manifest/types.rs @@ -5,10 +5,17 @@ use specta::Type; use springtale_crypto::signature::SignatureAlgorithm; /// A connector's manifest — the declaration of what it is, what it needs, -/// and what it can do. Parsed from `connector-{name}.toml`. +/// and what it can do. /// -/// Every connector ships with a manifest. For native connectors, the manifest -/// is embedded. For WASM connectors, it accompanies the `.wasm` binary. +/// Built in code, not parsed from a file: every connector constructs and +/// returns this struct from its `Connector::manifest()` implementation, so +/// the declaration is compiled and type-checked alongside the `triggers()` +/// and `actions()` it describes. There is no `connector-{name}.toml`. +/// +/// The `Serialize`/`Deserialize` impls exist because the manifest is +/// canonicalised to JSON for signing (see `signature_alg` / `signature`) +/// and travels with a WASM connector's `.wasm` binary at install time — +/// not because it is authored as a config file. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Type)] #[serde(deny_unknown_fields)] pub struct ConnectorManifest { diff --git a/crates/springtale-cooperation/benches/formation_scaling.rs b/crates/springtale-cooperation/benches/formation_scaling.rs index 00ac9be0..a217ad36 100644 --- a/crates/springtale-cooperation/benches/formation_scaling.rs +++ b/crates/springtale-cooperation/benches/formation_scaling.rs @@ -39,6 +39,7 @@ fn synthetic_report(i: usize, tick: u64) -> TickReport { latency: Duration::from_millis(5), intent_alignment: 0.95, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/benches/rally_cascade.rs b/crates/springtale-cooperation/benches/rally_cascade.rs index f066ff5a..a39110f3 100644 --- a/crates/springtale-cooperation/benches/rally_cascade.rs +++ b/crates/springtale-cooperation/benches/rally_cascade.rs @@ -33,6 +33,7 @@ fn synth_report(agent: AgentId, alignment: f32) -> TickReport { latency: Duration::from_millis(5), intent_alignment: alignment, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/src/agent/step/react.rs b/crates/springtale-cooperation/src/agent/step/react.rs index d6396d43..02de3ca5 100644 --- a/crates/springtale-cooperation/src/agent/step/react.rs +++ b/crates/springtale-cooperation/src/agent/step/react.rs @@ -41,15 +41,30 @@ fn apply(awareness: &mut LocalAwareness, msg: StateMessage) { // a thought bubble is private to the speaker and the observer. StateMessage::Utterance(u) if u.carrier.heard_by_peers() => { let Some(agent) = u.agent else { return }; - if let Some(n) = awareness.neighbor_states.get_mut(&agent) { - match u.utterance { - UtteranceKind::Failed => n.last_action_success = false, - UtteranceKind::Working - | UtteranceKind::Firing - | UtteranceKind::Claimed { .. } => n.last_action_success = true, - UtteranceKind::Down => n.liveness = Liveness::Down { since_tick: u.seq }, - _ => {} + let Some(n) = awareness.neighbor_states.get_mut(&agent) else { + return; + }; + // What was heard is also remembered on `heard_failures`: the + // beat's gossip merge republishes every neighbor snapshot and + // would otherwise overwrite this fold before anything acted + // on it (see `LocalAwareness::merge_neighbor`). + let mut heard: Option = None; + match u.utterance { + UtteranceKind::Failed => { + n.last_action_success = false; + heard = Some(false); } + UtteranceKind::Working | UtteranceKind::Firing | UtteranceKind::Claimed { .. } => { + n.last_action_success = true; + heard = Some(true); + } + UtteranceKind::Down => n.liveness = Liveness::Down { since_tick: u.seq }, + _ => {} + } + match heard { + Some(false) => awareness.heard_failure(agent), + Some(true) => awareness.heard_progress(&agent), + None => {} } } _ => {} @@ -166,6 +181,58 @@ mod tests { )); } + /// Fix 5 — a heard failure survives the tick. + /// + /// The fold used to be overwritten by the same beat's gossip merge, + /// which republishes every neighbor snapshot and reports + /// `last_action_success: true` for a peer that filed no tick report. + /// The heard failure now wins that merge, and it moves morale — a + /// real consumer, which cascade detection reads. + #[test] + fn test_heard_failure_survives_the_gossip_merge_and_moves_morale() { + let a = AgentId(uuid::Uuid::new_v4()); + let mut awareness = awareness_with_neighbor(a); + let calm = awareness.morale_target(); + + let mut bus = VecBus { + msgs: vec![utterance( + a, + UtteranceKind::Failed, + crate::utterance::Carrier::Burst, + )] + .into(), + }; + run(&mut bus, &mut awareness, MomentumTier::Warming); + assert!(!awareness.neighbor_states[&a].last_action_success); + assert!(awareness.heard_failures.contains(&a)); + + // The beat's gossip merge: `a` filed no report, so gossip says it + // succeeded. What this agent heard wins. + let mut fresh = awareness.neighbor_states[&a].clone(); + fresh.last_action_success = true; + fresh.last_updated = Instant::now(); + awareness.merge_neighbor(fresh.clone()); + assert!(!awareness.neighbor_states[&a].last_action_success); + assert!(awareness.morale_target() < calm); + + // Consumed: the next beat's gossip is authoritative again. + awareness.merge_neighbor(fresh); + assert!(awareness.neighbor_states[&a].last_action_success); + + // And hearing the peer work clears the memory outright. + awareness.heard_failure(a); + let mut bus = VecBus { + msgs: vec![utterance( + a, + UtteranceKind::Firing, + crate::utterance::Carrier::Burst, + )] + .into(), + }; + run(&mut bus, &mut awareness, MomentumTier::Warming); + assert!(awareness.heard_failures.is_empty()); + } + #[test] fn test_apply_thought_utterance_is_private_and_ignored() { let a = AgentId(uuid::Uuid::new_v4()); diff --git a/crates/springtale-cooperation/src/awareness/mod.rs b/crates/springtale-cooperation/src/awareness/mod.rs index 5e2bc0bb..0255a6fd 100644 --- a/crates/springtale-cooperation/src/awareness/mod.rs +++ b/crates/springtale-cooperation/src/awareness/mod.rs @@ -12,4 +12,6 @@ mod types; pub use bridge::{GossipEntry, InMemoryGossipStore}; pub use store::{ChitchatGossipConfig, ChitchatGossipStore, GossipStore}; pub use swim::{ProcId, SwimEvent, SwimNode, SwimNodeConfig, SwimSelfState}; -pub use types::{LocalAwareness, NeighborSnapshot, RoleSignature}; +pub use types::{ + FAILURE_PENALTY, LocalAwareness, NeighborSnapshot, RoleSignature, SHATTERED_MORALE, +}; diff --git a/crates/springtale-cooperation/src/awareness/types.rs b/crates/springtale-cooperation/src/awareness/types.rs index ca19fbe4..c352cc6b 100644 --- a/crates/springtale-cooperation/src/awareness/types.rs +++ b/crates/springtale-cooperation/src/awareness/types.rs @@ -12,7 +12,7 @@ //! //! Available at Warming+ tier (§7 capability table). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::Instant; use serde::{Deserialize, Serialize}; @@ -34,6 +34,14 @@ pub const MORALE_MIN_STEP: f32 = 0.01; /// (decisions §11 #7). The WH3 `max_routing_enemies_to_consider = 5` cap has no /// analog in a cooperative (non-adversarial) formation, so only this cap applies. pub const MAX_CONTAGION_DISTRESSED: usize = 4; +/// Total War's shattered floor (§15.2): below this morale a unit is past +/// rallying — the general's call cannot reach it. `rally::cascade` refuses +/// to spend a rally token on a member at or under it. +pub const SHATTERED_MORALE: f32 = 0.1; +/// Weight of "a neighbor's last action failed" in [`LocalAwareness::morale_target`]. +/// Smaller than the distress penalty: a failed action is a stumble, an +/// incapacitated neighbor is a casualty. +pub const FAILURE_PENALTY: f32 = 0.15; /// §A.4 rally falloff (decisions §11 #6), non-spatial analog. WH3's rally /// aura is full-strength out to `general_aura_radius = 70` units, then @@ -162,6 +170,17 @@ pub struct LocalAwareness { /// read via [`Self::local_morale`]. Stateful (not instantaneous) so routing /// is gradual and bounded, per decisions §11 #8. pub morale: f32, + /// Peers this agent HEARD fail (an L2 `Failed` utterance folded in by + /// `agent::step::react`) since the last gossip merge. + /// + /// Gossip republishes every neighbor's snapshot each beat from the + /// beat's tick reports, and a peer that reported nothing publishes + /// `last_action_success: true` — which used to overwrite what this + /// agent heard, in the same tick, before anything could act on it. + /// [`Self::merge_neighbor`] folds this set into the incoming snapshot + /// and consumes it, so a heard failure survives exactly the beat it + /// was heard on and then ages out normally. + pub heard_failures: HashSet, } impl Default for LocalAwareness { @@ -171,16 +190,43 @@ impl Default for LocalAwareness { formation_momentum: MomentumTier::Cold, last_tick_reports: Vec::new(), morale: 0.5, // neutral + heard_failures: HashSet::new(), } } } impl LocalAwareness { - /// Update a neighbor's snapshot. + /// Update a neighbor's snapshot, replacing what was known about it. pub fn update_neighbor(&mut self, snapshot: NeighborSnapshot) { self.neighbor_states.insert(snapshot.agent_id, snapshot); } + /// Merge a gossip snapshot over what is known, keeping what this agent + /// HEARD this beat where gossip cannot know better. + /// + /// Gossip derives `last_action_success` from the beat's tick reports + /// and defaults to `true` for a member that reported nothing, so a + /// plain [`Self::update_neighbor`] silently discarded the L2 utterance + /// fold. The heard failure wins and is consumed: the next beat's + /// gossip is authoritative again. + pub fn merge_neighbor(&mut self, mut snapshot: NeighborSnapshot) { + if self.heard_failures.remove(&snapshot.agent_id) { + snapshot.last_action_success = false; + } + self.neighbor_states.insert(snapshot.agent_id, snapshot); + } + + /// Record that a peer was heard failing (L2 `Failed` utterance). + pub fn heard_failure(&mut self, agent: AgentId) { + self.heard_failures.insert(agent); + } + + /// Record that a peer was heard working again — it is no longer the + /// last thing this agent heard from it. + pub fn heard_progress(&mut self, agent: &AgentId) { + self.heard_failures.remove(agent); + } + /// Remove a neighbor (disconnected or dead). pub fn remove_neighbor(&mut self, agent_id: &AgentId) { self.neighbor_states.remove(agent_id); @@ -256,10 +302,25 @@ impl LocalAwareness { .sum::() .min(MAX_CONTAGION_DISTRESSED as f32); + // Neighbors whose LAST ACTION failed — heard directly (§19 implicit + // signals, folded by `agent::step::react`) or read off the beat's + // gossip. Total War: a unit whose neighbors are losing their fight + // wavers before any of them is a casualty. Bounded by the same + // contagion cap so a bad beat cannot rout a whole formation. + let failing: f32 = self + .neighbor_states + .values() + .filter(|n| !n.last_action_success) + .map(|n| aoi_weight(n.last_updated.elapsed())) + .sum::() + .min(MAX_CONTAGION_DISTRESSED as f32); + // Base morale from (AoI-weighted) neighbor health ratio. let health_factor = healthy / total; // Penalty for distressed neighbors (cascade risk). let distress_penalty = distressed / total * 0.3; + // Penalty for neighbors whose last action failed. + let failure_penalty = failing / total * FAILURE_PENALTY; // Momentum bonus. let momentum_bonus = match self.formation_momentum { MomentumTier::Cold => 0.0, @@ -268,7 +329,7 @@ impl LocalAwareness { MomentumTier::Fever => 0.2, }; - (health_factor - distress_penalty + momentum_bonus).clamp(0.0, 1.0) + (health_factor - distress_penalty - failure_penalty + momentum_bonus).clamp(0.0, 1.0) } /// Advance the lerped morale one tick toward [`Self::morale_target`] at @@ -486,6 +547,7 @@ mod tests { latency: std::time::Duration::from_millis(10), intent_alignment: 0.8, interference_with: vec![my_id], + state: crate::action_state::ActionState::Success, }; awareness.record_tick_reports(vec![report]); diff --git a/crates/springtale-cooperation/src/cadence.rs b/crates/springtale-cooperation/src/cadence.rs index ca953497..2eb4d6b4 100644 --- a/crates/springtale-cooperation/src/cadence.rs +++ b/crates/springtale-cooperation/src/cadence.rs @@ -19,6 +19,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; + +use crate::action_state::ActionState; use specta::Type; use tokio::sync::broadcast; @@ -212,6 +214,16 @@ pub struct TickReport { pub intent_alignment: f32, /// Agents this action interfered with (Helldivers friendly fire). pub interference_with: Vec, + /// Lifecycle state the member's action reached this beat. + /// + /// The momentum step classifies on this, not on `intent_alignment`: + /// only a terminal state (`Success` / `Failure`) is work the beat + /// finished. A carried-over dispatch (`Requested`), a bare claim + /// (`Init`) and a cancelled action are idle for momentum however + /// well they align with the intent — otherwise a hung connector call + /// or an observe-autonomy member walks the formation to Fever with + /// nothing done. + pub state: ActionState, } /// The shared tick bus that all formation members synchronize to. @@ -330,6 +342,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: 0.95, interference_with: vec![], + state: crate::action_state::ActionState::Success, }) .await .expect("send report"); @@ -357,6 +370,7 @@ mod tests { latency: Duration::from_millis(0), intent_alignment: 0.5, interference_with: vec![], + state: crate::action_state::ActionState::Success, }) .await .expect("send"); diff --git a/crates/springtale-cooperation/src/interference/detector.rs b/crates/springtale-cooperation/src/interference/detector.rs index 7ec3c121..0884c854 100644 --- a/crates/springtale-cooperation/src/interference/detector.rs +++ b/crates/springtale-cooperation/src/interference/detector.rs @@ -391,6 +391,7 @@ mod tests { latency: Duration::from_millis(0), intent_alignment: 1.0, interference_with: vec![b], + state: crate::action_state::ActionState::Success, }, TickReport { agent_id: b, @@ -399,6 +400,7 @@ mod tests { latency: Duration::from_millis(0), intent_alignment: 1.0, interference_with: vec![a], + state: crate::action_state::ActionState::Success, }, ]; let events = detect(&reports); diff --git a/crates/springtale-cooperation/src/mental_model/learning.rs b/crates/springtale-cooperation/src/mental_model/learning.rs index 485df730..56392f04 100644 --- a/crates/springtale-cooperation/src/mental_model/learning.rs +++ b/crates/springtale-cooperation/src/mental_model/learning.rs @@ -140,6 +140,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: 0.9, interference_with: vec![], + state: crate::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/src/rally/cascade.rs b/crates/springtale-cooperation/src/rally/cascade.rs index ee22aa77..3f59f5b0 100644 --- a/crates/springtale-cooperation/src/rally/cascade.rs +++ b/crates/springtale-cooperation/src/rally/cascade.rs @@ -9,12 +9,12 @@ //! 3. Reduce momentum tier to match reduced coherence //! 4. Consume rally token (limited, like Monster Hunter carts) -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use crate::action_state::ActionState; use crate::attention::AttentionBroker; -use crate::awareness::LocalAwareness; +use crate::awareness::{LocalAwareness, SHATTERED_MORALE}; use crate::cadence::AgentId; -use crate::momentum::MomentumState; use crate::tick_processor::FormationTickResult; use super::{FormationRally, RallyEvent, RallyFailure, RallyResult}; @@ -75,13 +75,70 @@ pub fn detect_cascade( } } +/// Pick the member a rally token should be spent on. +/// +/// Per Total War (spec §15.2): a general rallies the unit that can still +/// answer him. A shattered unit is past rallying and a dead one cannot +/// hear it, so a token spent on either is a token wasted. Of the members +/// that failed this beat we take the one with the LOWEST morale that is +/// still above [`SHATTERED_MORALE`] — the closest to breaking that can +/// still be pulled back. +/// +/// `candidates` holds only members the caller considers alive and +/// operational (the bot's `check_cascade` builds it from +/// `FormationMember::is_operational`, which excludes `Incapacitated` and +/// `Dead`), so absence from the map is the "cannot respond" answer. +/// `None` means no token should be spent at all. +/// +/// `failing` is walked in order so ties resolve deterministically. +pub fn select_rally_target( + candidates: &HashMap, + failing: &[AgentId], +) -> Option { + let mut best: Option<(AgentId, f32)> = None; + for agent in failing { + let Some(awareness) = candidates.get(agent) else { + continue; // incapacitated, dead, or gone: cannot respond + }; + let morale = awareness.local_morale(); + if morale <= SHATTERED_MORALE { + continue; // shattered: past rallying (Total War §15.2) + } + if best.is_none_or(|(_, lowest)| morale < lowest) { + best = Some((*agent, morale)); + } + } + best.map(|(agent, _)| agent) +} + +/// Did a member we spent a rally token on come back? +/// +/// `RallyResult::Recovered` is the answer to a rally on a LATER beat: +/// the token bought the member a chance and it finished work with it. +/// `rallied` is the set of members with a token spent on them since the +/// last recovery; `Some(Recovered)` means the caller should clear it. +pub fn recovered(rallied: &HashSet, result: &FormationTickResult) -> Option { + if rallied.is_empty() { + return None; + } + result + .reports + .iter() + .any(|r| { + rallied.contains(&r.agent_id) + && matches!(r.state, ActionState::Success) + && r.intent_alignment > 0.5 + }) + .then_some(RallyResult::Recovered) +} + /// Attempt formation self-rally before escalating to orchestrator. /// /// Per §15.2 (Monster Hunter cart system): /// 1. Redistribute attention away from failing agent -/// 2. Reduce momentum to match reduced coherence -/// 3. Consume a rally token -/// 4. If no tokens left → escalate +/// 2. Consume a rally token (the tick's own momentum step already +/// recorded the failure — see the comment in the body) +/// 3. If no tokens left → escalate /// /// Takes `&FormationRally` (not `&mut`): the token pool is backed by /// `Arc` (interior-mutable), the event channel is a @@ -89,7 +146,6 @@ pub fn detect_cascade( pub fn attempt_self_rally( rally: &FormationRally, attention: &AttentionBroker, - momentum: &mut MomentumState, failing_agent: AgentId, ) -> RallyResult { if !rally.tokens.can_rally() { @@ -107,10 +163,14 @@ pub fn attempt_self_rally( from: failing_agent, }); - // 2. Reduce momentum — the formation lost coherence - momentum.record_failure(); + // The formation's lost coherence is NOT recorded here. The beat that + // produced this cascade already ran `update_momentum`, which recorded + // the same failed tick; recording it again demoted the formation twice + // for one bad beat (and `supervision`/`handle_command` rallies would + // have invented a failure that no tick reported). Momentum is the + // momentum step's to own — the rally's cost is the token. - // 3. Consume rally token. `consume()` fails only if something closed + // 2. Consume rally token. `consume()` fails only if something closed // the semaphore between the `can_rally` check and here; treat as // escalation. match rally.tokens.consume() { @@ -141,9 +201,14 @@ pub fn attempt_self_rally( mod tests { use super::*; use crate::cadence::TickReport; + use crate::momentum::MomentumState; use std::time::Duration; fn make_report(agent: AgentId, alignment: f32) -> TickReport { + make_stated(agent, alignment, ActionState::Success) + } + + fn make_stated(agent: AgentId, alignment: f32, state: ActionState) -> TickReport { TickReport { agent_id: agent, tick_sequence: crate::tick::TickId(1), @@ -155,6 +220,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: alignment, interference_with: vec![], + state, } } @@ -254,7 +320,9 @@ mod tests { momentum.record_success(); } - let result = attempt_self_rally(&rally, &attention, &mut momentum, a); + let before = momentum.tier; + let successes = momentum.consecutive_successes; + let result = attempt_self_rally(&rally, &attention, a); assert!(matches!( result, RallyResult::StabilizedWithCost { @@ -262,6 +330,10 @@ mod tests { } )); assert_eq!(rally.tokens.remaining(), 2); + // Fix 2 — the rally does not double-count the failure. The beat's + // momentum step already recorded it; the rally's cost is the token. + assert_eq!(momentum.tier, before); + assert_eq!(momentum.consecutive_successes, successes); } #[test] @@ -273,9 +345,94 @@ mod tests { rally.tokens.consume().unwrap(); rally.tokens.consume().unwrap(); let attention = AttentionBroker::for_agents(&[a]); - let mut momentum = MomentumState::default(); + let momentum = MomentumState::default(); - let result = attempt_self_rally(&rally, &attention, &mut momentum, a); + let result = attempt_self_rally(&rally, &attention, a); assert!(matches!(result, RallyResult::EscalateToOrchestrator { .. })); + assert_eq!(momentum.consecutive_successes, 0); + } + + /// Fix 2 — the token goes to the member that can still respond: the + /// lowest morale ABOVE the shattered floor. A shattered member and a + /// member missing from the map (dead/incapacitated) are skipped even + /// though they failed first. + #[test] + fn test_select_rally_target_is_lowest_morale_above_shattered() { + let dead = AgentId::new(); + let shattered = AgentId::new(); + let wavering = AgentId::new(); + let steady = AgentId::new(); + + let aw_shattered = LocalAwareness { + morale: SHATTERED_MORALE - 0.01, + ..Default::default() + }; + let aw_wavering = LocalAwareness { + morale: 0.25, + ..Default::default() + }; + let aw_steady = LocalAwareness { + morale: 0.8, + ..Default::default() + }; + + // `dead` is absent: `check_cascade` builds this map from + // operational members only. + let mut map: HashMap = HashMap::new(); + map.insert(shattered, &aw_shattered); + map.insert(wavering, &aw_wavering); + map.insert(steady, &aw_steady); + + let failing = [dead, shattered, wavering, steady]; + assert_eq!(select_rally_target(&map, &failing), Some(wavering)); + } + + /// No failing member can respond → no target, so no token is spent. + #[test] + fn test_select_rally_target_none_when_nobody_can_respond() { + let dead = AgentId::new(); + let shattered = AgentId::new(); + let aw = LocalAwareness { + morale: 0.0, + ..Default::default() + }; + let mut map: HashMap = HashMap::new(); + map.insert(shattered, &aw); + assert_eq!(select_rally_target(&map, &[dead, shattered]), None); + } + + /// Fix 3 — `Recovered` is emitted when a rallied member finishes work + /// on a later beat, and only then. + #[test] + fn test_recovered_only_when_a_rallied_member_completes_work() { + let rallied_agent = AgentId::new(); + let other = AgentId::new(); + let mut rallied = HashSet::new(); + rallied.insert(rallied_agent); + + let still_trying = FormationTickResult { + reports: vec![make_stated(rallied_agent, 0.8, ActionState::Requested)], + interferences: vec![], + all_succeeded: false, + }; + assert!(recovered(&rallied, &still_trying).is_none()); + + let someone_else = FormationTickResult { + reports: vec![make_report(other, 1.0)], + interferences: vec![], + all_succeeded: true, + }; + assert!(recovered(&rallied, &someone_else).is_none()); + + let came_back = FormationTickResult { + reports: vec![make_report(rallied_agent, 1.0)], + interferences: vec![], + all_succeeded: true, + }; + assert!(matches!( + recovered(&rallied, &came_back), + Some(RallyResult::Recovered) + )); + assert!(recovered(&HashSet::new(), &came_back).is_none()); } } diff --git a/crates/springtale-cooperation/src/tick_processor.rs b/crates/springtale-cooperation/src/tick_processor.rs index 19eebd5b..4df9e74a 100644 --- a/crates/springtale-cooperation/src/tick_processor.rs +++ b/crates/springtale-cooperation/src/tick_processor.rs @@ -120,6 +120,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: alignment, interference_with: interferes, + state: crate::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/tests/properties.rs b/crates/springtale-cooperation/tests/properties.rs index 5cf07829..37aaf4bc 100644 --- a/crates/springtale-cooperation/tests/properties.rs +++ b/crates/springtale-cooperation/tests/properties.rs @@ -182,6 +182,7 @@ fn make_report(agent: AgentId, kind: &str, target: Option<&str>, tick: u64) -> T latency: Duration::from_millis(5), intent_alignment: 0.9, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/tests/rally_cascade_recovery.rs b/crates/springtale-cooperation/tests/rally_cascade_recovery.rs index a9c65a39..46e93e9f 100644 --- a/crates/springtale-cooperation/tests/rally_cascade_recovery.rs +++ b/crates/springtale-cooperation/tests/rally_cascade_recovery.rs @@ -22,7 +22,6 @@ use springtale_cooperation::attention::AttentionBroker; use springtale_cooperation::cadence::AgentId; -use springtale_cooperation::momentum::MomentumState; use springtale_cooperation::rally::cascade::attempt_self_rally; use springtale_cooperation::rally::{FormationRally, RallyEvent, RallyResult}; @@ -45,7 +44,6 @@ fn cascade_consumes_tokens_then_escalates() { let rally = FormationRally::new(TOKEN_BUDGET, EVENT_CAP); let attention = AttentionBroker::for_agents(&agents); - let mut momentum = MomentumState::default(); // Sanity check: every agent is registered with the broker before // we begin so attention.release() has a valid neighbour set to @@ -71,7 +69,7 @@ fn cascade_consumes_tokens_then_escalates() { let expected_remaining: Vec = (0..TOKEN_BUDGET).rev().map(|n| n as u32).collect(); let mut seen_remaining = Vec::new(); for _ in 0..TOKEN_BUDGET { - let result = attempt_self_rally(&rally, &attention, &mut momentum, a); + let result = attempt_self_rally(&rally, &attention, a); match result { RallyResult::StabilizedWithCost { tokens_remaining } => { seen_remaining.push(tokens_remaining); @@ -89,7 +87,7 @@ fn cascade_consumes_tokens_then_escalates() { assert_eq!(rally.tokens.remaining(), 0); assert!(!rally.tokens.can_rally()); - let escalation = attempt_self_rally(&rally, &attention, &mut momentum, b); + let escalation = attempt_self_rally(&rally, &attention, b); match escalation { RallyResult::EscalateToOrchestrator { reason } => { assert!(reason.contains("exhausted"), "got reason: {reason}"); @@ -161,8 +159,7 @@ fn restore_tokens_reflects_persisted_state() { assert_eq!(rally.tokens.remaining(), 1); let attention = AttentionBroker::for_agents(&[a]); - let mut momentum = MomentumState::default(); - let first = attempt_self_rally(&rally, &attention, &mut momentum, a); + let first = attempt_self_rally(&rally, &attention, a); assert!(matches!( first, RallyResult::StabilizedWithCost { @@ -171,6 +168,6 @@ fn restore_tokens_reflects_persisted_state() { )); // Latch closed — restored exhausted state stays exhausted. - let second = attempt_self_rally(&rally, &attention, &mut momentum, a); + let second = attempt_self_rally(&rally, &attention, a); assert!(matches!(second, RallyResult::EscalateToOrchestrator { .. })); } diff --git a/crates/springtale-cooperation/tests/replay_determinism.rs b/crates/springtale-cooperation/tests/replay_determinism.rs index ab9fed81..b997c047 100644 --- a/crates/springtale-cooperation/tests/replay_determinism.rs +++ b/crates/springtale-cooperation/tests/replay_determinism.rs @@ -97,6 +97,7 @@ impl From<&ReportRecord> for TickReport { latency: Duration::from_millis(r.latency_ms), intent_alignment: r.intent_alignment, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, } } } @@ -116,6 +117,7 @@ fn synth_reports(tick: u64, n: usize) -> Vec { latency: Duration::from_millis(5), intent_alignment: 0.95, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, }) .collect() } diff --git a/crates/springtale-runtime/src/dispatch/step.rs b/crates/springtale-runtime/src/dispatch/step.rs index 9b41c83d..d4303bba 100644 --- a/crates/springtale-runtime/src/dispatch/step.rs +++ b/crates/springtale-runtime/src/dispatch/step.rs @@ -65,6 +65,11 @@ async fn run_step_inner( } = action { let reg = bridge.registry().read().await; + // AUTHORITATIVE SOURCE for advisory action hints: + // `ConnectorHost::actions()`, not `manifest().actions`. The + // consensus vote in `springtale-bot`'s executor reads the same + // method so a task cannot be voted on under one hint and + // classified by the sentinel under another. reg.get(connector) .and_then(|e| e.host.actions().iter().find(|d| d.name == *name).cloned()) .map(|d| ActionHints { diff --git a/crates/springtale-runtime/src/init.rs b/crates/springtale-runtime/src/init.rs index 590a5a7b..da2c3199 100644 --- a/crates/springtale-runtime/src/init.rs +++ b/crates/springtale-runtime/src/init.rs @@ -1004,11 +1004,15 @@ fn init_sentinel( /// ORIGINAL signing key — which they don't have. TUF §4 /// trust-anchor separation. /// -/// Legacy rows (pre-v8 migration) carry empty `author_pubkey_hex`; -/// these are treated as "TOFU-grandfathered" and logged at WARN. We -/// don't fail closed on them so an existing deployment isn't bricked -/// by the audit fix; the operator sees the warning and can re-install -/// the connector to repopulate the pin. +/// There is no grandfather clause. Every install path signs and pins +/// (`operations::connectors::install` rejects an unsigned or +/// unknown-author manifest before it ever calls `store_wasm_binary`, +/// and writes `author_pubkey_hex` + `manifest_sig_hex` from that +/// verified install), so a row with an empty pin is either a +/// pre-pinning row or a row an attacker blanked to skip step 2. +/// Both are refused: SECURITY.md's rule is "verify signature before +/// load", and a row we cannot verify does not load. The operator +/// re-installs the connector to repopulate the pin. fn reverify_persisted_wasm( name: &str, wasm_bytes: &[u8], @@ -1035,15 +1039,14 @@ fn reverify_persisted_wasm( // 2. Signature re-verify against the pinned trust anchor. if pinned_pubkey_hex.is_empty() || pinned_sig_hex.is_empty() { - // Legacy install (pre-v8) — log + accept. Operators re-install - // to upgrade the row to a pinned-pubkey one. - tracing::warn!( - connector = %name, - "WASM connector loaded without pinned author pubkey — \ - legacy pre-v8 install. Re-install to enable boot-time \ - signature re-verification (Phase-7 audit Finding #1)." - ); - return Ok(()); + // Fail closed. An empty pin means the row carries nothing to + // verify against, so this load would be unverified — exactly + // what SECURITY.md forbids. No override flag by design. + return Err(OperationError::Validation(format!( + "WASM connector {name} has no pinned author pubkey/signature — \ + refusing to load an unverifiable connector. Re-install {name} \ + to repin its signature." + ))); } let pubkey_bytes = hex::decode(pinned_pubkey_hex).map_err(|e| { @@ -1088,6 +1091,48 @@ fn reverify_persisted_wasm( mod tests { use super::*; + /// A persisted row whose trust-anchor columns were never written + /// (or were blanked by an attacker to skip signature checking) + /// must not load. There is no grandfather path. + #[test] + fn test_reverify_persisted_wasm_empty_pins_refuses_to_load() { + use sha2::Digest; + + let wasm_bytes = b"\0asm\x01\0\0\0".to_vec(); + let hash = hex::encode(sha2::Sha256::digest(&wasm_bytes)); + let manifest: springtale_connector::ConnectorManifest = + serde_json::from_value(serde_json::json!({ + "name": "connector-unpinned", + "version": "1.0.0", + "author": "nobody", + "description": "row with no pinned signature", + "capabilities": [], + "wasm_hash": hash, + })) + .expect("manifest fixture parses"); + + for (pubkey, sig) in [("", ""), ("", "aa"), ("bb", "")] { + let err = reverify_persisted_wasm( + "connector-unpinned", + &wasm_bytes, + &hash, + &manifest, + pubkey, + sig, + ) + .expect_err("a row with an empty pin must be refused"); + let msg = err.to_string(); + assert!( + msg.contains("connector-unpinned"), + "refusal names the connector: {msg}" + ); + assert!( + msg.contains("Re-install"), + "refusal tells the operator to reinstall: {msg}" + ); + } + } + #[test] fn test_acquire_runtime_lock_second_holder_rejected_until_first_drops() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/springtale-runtime/src/operations/agent.rs b/crates/springtale-runtime/src/operations/agent.rs index cca5b81a..f1e237fb 100644 --- a/crates/springtale-runtime/src/operations/agent.rs +++ b/crates/springtale-runtime/src/operations/agent.rs @@ -12,10 +12,10 @@ use serde::Serialize; use specta::Type; use springtale_cooperation::AutonomyLevel; +use springtale_cooperation::utterance::Utterance; use springtale_core::rule::action::Action; use springtale_core::rule::types::Rule; use springtale_store::StorageBackend; -use springtale_store::schema::events::{EventEntry, EventFilter}; use crate::error::OperationError; use crate::state::RuntimeState; @@ -254,48 +254,49 @@ fn infer_role(trigger_type: &str) -> &'static str { } } -/// Compute agent activity from recent events. +/// The resting activity: an agent that has said nothing unexpired. /// -/// Moved from frontend `ColonyCanvas.tsx` — event interpretation -/// belongs in the backend, not derived client-side. -fn compute_activity( - connector_name: &Option, - trigger_type: &str, - status: &str, - events: &[EventEntry], -) -> &'static str { - if status != "enabled" { - return "idle"; - } - - // Find the most recent event matching this agent's connector or trigger - let latest = events.iter().find(|e| { - connector_name - .as_ref() - .is_some_and(|cn| e.connector_name == *cn) - || e.trigger_type == trigger_type - }); +/// Same value as the canvas's `SILENT_ACTIVITY` +/// (`tauri/packages/ui/src/dashboard/activity.ts`). +pub const SILENT_ACTIVITY: &str = "listening"; - let Some(event) = latest else { - return "waiting"; - }; +/// An agent's activity is what it SAID — the newest unexpired utterance +/// in the cooperation ring (plan §1.15 F). +/// +/// This is the same derivation the canvas does over the same ring +/// (`activityOf` in `dashboard/activity.ts`), done once at fetch time so +/// a polling client and a streaming one agree. Deriving it from the event +/// log instead made the server disagree with every browser: the log knows +/// what ran, not what the agent is saying about it. +/// +/// Matching follows the canvas: a solo agent by the `rule_id` its +/// utterances are stamped with, a formation member by its cooperation +/// `agent_id`. `utterances` is the ring, newest first; `now` is the +/// colony tick clock, and an utterance is live while +/// `seq + ttl_ticks > now`. +fn activity_from_utterances( + utterances: &[Utterance], + rule_id: &str, + agent_id: Option<&str>, + now: u64, +) -> Option { + utterances + .iter() + .find(|u| { + let matches_rule = u.rule_id.is_some_and(|r| r.0.to_string() == rule_id); + let matches_agent = match (u.agent, agent_id) { + (Some(a), Some(want)) => a.0.to_string() == want, + _ => false, + }; + (matches_rule || matches_agent) && u.seq.0.saturating_add(u64::from(u.ttl_ticks)) > now + }) + .map(|u| u.utterance.name().to_owned()) +} - let age_ms = (chrono::Utc::now() - event.timestamp).num_milliseconds(); - if age_ms < 5_000 { - // Check for error indicators in the action text - let action_lower = event.action_taken.to_lowercase(); - if action_lower.contains("error") - || action_lower.contains("fail") - || action_lower.contains("block") - { - return "error"; - } - return "firing"; - } - if age_ms < 60_000 { - return "active"; - } - "waiting" +/// The colony tick clock as the ring knows it: the newest sequence any +/// utterance carries. An empty ring has no clock, so nothing is expired. +fn ring_now(utterances: &[Utterance]) -> u64 { + utterances.iter().map(|u| u.seq.0).max().unwrap_or(0) } /// Autonomy level to its L0–L3 index. @@ -311,6 +312,10 @@ fn autonomy_to_index(level: AutonomyLevel) -> u8 { /// Live formation member data — used to enrich AgentState with /// real cooperation data when formations are active. struct LiveAgentEnrichment { + /// Cooperation-layer agent id — how a formation member's utterances + /// are addressed. Without it the server could not match a member's + /// utterance and disagreed with the browser about its activity. + agent_id: String, attention_load: f32, liveness: f32, health_state: String, @@ -364,15 +369,11 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, .collect() }; - // Fetch recent events (last 200) for activity computation - let events = state - .store - .list_events(&EventFilter { - limit: Some(200), - ..Default::default() - }) - .await - .map_err(OperationError::Store)?; + // Activity comes from the utterance ring, not the event log: what an + // agent is doing is what it last said, and the canvas derives it from + // the same ring (plan §1.15 F). + let utterances = crate::utterance_ring::recent(&state.utterances).await; + let now = ring_now(&utterances); // Fetch all config rows once; autonomy is keyed `autonomy:agent:{rule_id}`. let config = state @@ -393,6 +394,7 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, enrichment_map.insert( detail.connector_name.clone(), LiveAgentEnrichment { + agent_id: detail.agent_id.clone(), attention_load: detail.attention_load, liveness: match detail.liveness.as_str() { "Alive" => 1.0, @@ -417,7 +419,21 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, .and_then(|(_, v)| parse_level_opt(v)) .unwrap_or(AutonomyLevel::ActAutonomously); - let activity = compute_activity(&r.connector_name, &r.trigger_type, &r.status, &events); + let enrichment = r + .connector_name + .as_ref() + .and_then(|cn| enrichment_map.get(cn)); + let activity = if r.status == "enabled" { + activity_from_utterances( + &utterances, + &r.id, + enrichment.map(|e| e.agent_id.as_str()), + now, + ) + .unwrap_or_else(|| SILENT_ACTIVITY.to_owned()) + } else { + "idle".to_owned() + }; let task_display = if activity == "idle" { "Idle".to_owned() } else { @@ -433,12 +449,6 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, } .to_owned(); - // Enrich from live formation data when available - let enrichment = r - .connector_name - .as_ref() - .and_then(|cn| enrichment_map.get(cn)); - // Live members report their real fuel budget; rules outside a // live formation have no budget, so enabled reads full and // disabled reads empty. @@ -458,7 +468,7 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, action_connector: action_targets.get(&r.id).cloned(), role: infer_role(&r.trigger_type).to_owned(), fuel, - activity: activity.to_owned(), + activity, autonomy: autonomy_idx, autonomy_label, fuel_status, @@ -543,6 +553,64 @@ mod tests { assert_eq!(fuel_status_label(21), "warn"); assert_eq!(fuel_status_label(20), "critical"); } + + /// Fix 4 — agent state activity comes from an utterance, not from the + /// event log. A solo agent matches on its rule id, a formation member + /// on its cooperation agent id, and an expired ring leaves the agent + /// `listening`. + #[test] + fn test_activity_comes_from_the_utterance_ring() { + use springtale_cooperation::TickId; + use springtale_cooperation::utterance::{UtteranceDefs, UtteranceKind, emit_solo}; + + let defs = UtteranceDefs::default(); + let rule = springtale_core::rule::RuleId::new(); + let other_rule = springtale_core::rule::RuleId::new(); + let mut ring: Vec = Vec::new(); + // Newest first, as the ring stores them. + let mut firing = emit_solo(None, &defs, rule, TickId(10), UtteranceKind::Firing) + .expect("firing has a def"); + let member = springtale_cooperation::cadence::AgentId::new(); + let mut member_failed = + emit_solo(None, &defs, other_rule, TickId(10), UtteranceKind::Failed) + .expect("failed has a def"); + member_failed.rule_id = None; + member_failed.agent = Some(member); + ring.push(member_failed); + ring.push(firing.clone()); + + let now = ring_now(&ring); + assert_eq!(now, 10); + assert_eq!( + activity_from_utterances(&ring, &rule.0.to_string(), None, now).as_deref(), + Some("firing") + ); + assert_eq!( + activity_from_utterances( + &ring, + &other_rule.0.to_string(), + Some(&member.0.to_string()), + now + ) + .as_deref(), + Some("failed") + ); + // Someone else's utterance is not this agent's activity. + assert!( + activity_from_utterances( + &ring, + &springtale_core::rule::RuleId::new().0.to_string(), + None, + now + ) + .is_none() + ); + // Expired: `seq + ttl_ticks <= now` — the agent falls back to + // `listening`. + firing.ttl_ticks = 1; + let expired = vec![firing]; + assert!(activity_from_utterances(&expired, &rule.0.to_string(), None, 99).is_none()); + } } /// Request body for setting an agent's autonomy level. diff --git a/crates/springtale-runtime/tests/wasm_reverify.rs b/crates/springtale-runtime/tests/wasm_reverify.rs index a454c253..9334fbad 100644 --- a/crates/springtale-runtime/tests/wasm_reverify.rs +++ b/crates/springtale-runtime/tests/wasm_reverify.rs @@ -11,7 +11,9 @@ //! no longer verifies against the new canonical bytes). //! 3. Tampered manifest WITH a fresh attacker-signed signature: the //! pinned-sig-vs-manifest-sig check at the verifier must reject. -//! 4. Tampered pinned pubkey row WITH a fresh attacker-signed +//! 4. A row with an empty pinned pubkey/signature — unverifiable, so +//! refused rather than grandfathered. +//! 5. Tampered pinned pubkey row WITH a fresh attacker-signed //! manifest: signature verifies against the attacker key but the //! sig pin still matches. (This case requires the attacker to //! have write access to BOTH the manifest_json + the pinned @@ -143,27 +145,31 @@ async fn tampered_manifest_with_original_sig_rejected_at_boot() { } #[tokio::test] -async fn legacy_pre_v8_row_loads_with_warning() { - // Empty pinned pubkey + empty pinned sig = legacy install. Should - // NOT fail closed (so existing deployments aren't bricked) but - // should log + load. The function returns Ok(()) on this path. +async fn unpinned_row_is_refused() { + // Empty pinned pubkey and/or empty pinned sig: nothing to verify + // against, so the load would be unverified. SECURITY.md requires a + // signature check before every load, so this fails closed. There is + // no grandfather path — every install signs and pins, so an empty + // pin is either pre-pinning or an attacker blanking the column. let wasm_bytes = b"legacy-wasm".to_vec(); let mut manifest = fresh_manifest(); manifest.wasm_hash = Some(sha256_hex(&wasm_bytes)); // No signature, no pinned pubkey, no pinned sig. - let result = call_reverify( - "connector-legacy", - &wasm_bytes, - manifest.wasm_hash.as_deref().unwrap(), - &manifest, - "", - "", - ); - assert!( - result.is_ok(), - "legacy row must load without failing: {result:?}" - ); + for (pubkey, sig) in [("", ""), ("", "aa"), ("bb", "")] { + let result = call_reverify( + "connector-legacy", + &wasm_bytes, + manifest.wasm_hash.as_deref().unwrap(), + &manifest, + pubkey, + sig, + ); + assert!( + result.is_err(), + "an unpinned row must be refused (pubkey={pubkey:?} sig={sig:?}): {result:?}" + ); + } } #[tokio::test] @@ -213,7 +219,9 @@ fn call_reverify( } // 2. signature if pinned_pubkey_hex.is_empty() || pinned_sig_hex.is_empty() { - return Ok(()); // legacy + // No grandfather clause: an unpinned row is unverifiable and + // must not load. Mirrors init.rs::reverify_persisted_wasm. + return Err(format!("no pinned author pubkey/signature for {name}")); } let pubkey_bytes = hex::decode(pinned_pubkey_hex).map_err(|e| format!("hex decode pubkey: {e}"))?; diff --git a/crates/springtale-sentinel/src/approval.rs b/crates/springtale-sentinel/src/approval.rs index a46d0c1e..08a0030b 100644 --- a/crates/springtale-sentinel/src/approval.rs +++ b/crates/springtale-sentinel/src/approval.rs @@ -18,8 +18,9 @@ //! exactly this — the desktop app constructs one, hands it to //! the sentinel, and listens on the receiver to dispatch each //! request to a Tauri event. -//! - **Tests** — [`AutoAllowApprovalGate`] removes the gate from -//! the path, useful for asserting other sentinel checks. +//! - **Tests** — `AutoAllowApprovalGate` removes the gate from the +//! path, useful for asserting other sentinel checks. It is +//! `#[cfg(test)]`-only: a production build cannot construct it. //! //! The trait is async because real implementations must await //! either a network round-trip or a UI confirmation. Default impls @@ -78,10 +79,19 @@ impl ApprovalGate for DefaultDenyApprovalGate { } } -/// Test-only convenience: every destructive action proceeds. Never -/// wire this in production paths. +/// Test-only: every destructive action proceeds. +/// +/// `#[cfg(test)]` is deliberate and is the security boundary. This type +/// disables the human approval gate wholesale, so a production build of +/// this crate must not be able to name it — otherwise any caller of +/// `Sentinel::with_approval_gate` could hand it in and silently turn the +/// gate off. Out-of-crate tests that want an auto-allow gate implement +/// the two-line [`ApprovalGate`] themselves rather than re-exporting +/// this one; see `apps/springtaled/tests/event_recipe_e2e.rs`. +#[cfg(test)] pub struct AutoAllowApprovalGate; +#[cfg(test)] #[async_trait] impl ApprovalGate for AutoAllowApprovalGate { async fn request_approval(&self, _request: ApprovalRequest) -> bool { diff --git a/crates/springtale-sentinel/src/lib.rs b/crates/springtale-sentinel/src/lib.rs index 82bc9c4c..954d3e0d 100644 --- a/crates/springtale-sentinel/src/lib.rs +++ b/crates/springtale-sentinel/src/lib.rs @@ -17,9 +17,11 @@ pub mod throttle_tier; pub mod toxic_pairs; pub mod verdict; +// `approval::AutoAllowApprovalGate` is deliberately NOT re-exported: it +// is `#[cfg(test)]`-only so no production caller of +// `Sentinel::with_approval_gate` can disable the human approval gate. pub use approval::{ - ApprovalGate, ApprovalRequest, AutoAllowApprovalGate, ChannelApprovalGate, - DefaultDenyApprovalGate, PendingApproval, + ApprovalGate, ApprovalRequest, ChannelApprovalGate, DefaultDenyApprovalGate, PendingApproval, }; pub use config::SentinelConfig; pub use error::SentinelError; diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 5cb61672..eee4b5ed 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -86,28 +86,50 @@ docker build -t springtale . ### 3.2. Run +The passphrase goes in a **file**, never in the environment. `docker-compose.yml` +mounts it as a Docker secret and points `SPRINGTALE_PASSPHRASE_FILE` at the +mount, which is the first source `get_passphrase()` consults at boot. + ```bash -mkdir -p data +mkdir -p data secrets cp springtale.toml.example springtale.toml -# Set your vault passphrase -export SPRINGTALE_PASSPHRASE="your-secure-passphrase" +# Write the passphrase to the secret file. `-n` matters: no trailing +# newline. Leading `space` keeps it out of shell history on bash/zsh +# with HISTCONTROL=ignorespace / setopt HIST_IGNORE_SPACE. + printf '%s' 'your-secure-passphrase' > secrets/passphrase.txt +chmod 600 secrets/passphrase.txt + +# Create the vault before the first `up` +docker compose run --rm springtaled springtale init docker compose up -d ``` +Do **not** use `export SPRINGTALE_PASSPHRASE=...`. Any process running as +the same user can read another process's environment out of +`/proc//environ`, `docker inspect` prints it back in the clear, and it +is inherited by every child process. `SPRINGTALE_PASSPHRASE` still exists as +a development-only fallback; production and anything holding real user data +should use the file. + The container runs as a non-root user with read-only root filesystem, all capabilities dropped, and `no-new-privileges` enforced. **TABLE III. DOCKER ENVIRONMENT VARIABLES** | Variable | Default | Description | |----------|---------|-------------| -| `SPRINGTALE_PASSPHRASE` | (required) | Vault encryption passphrase | +| `SPRINGTALE_PASSPHRASE_FILE` | `/run/secrets/springtale_passphrase` | Path to the file holding the vault passphrase. Read as bytes, trailing whitespace trimmed, zeroized after use. **Preferred.** | +| `SPRINGTALE_PASSPHRASE` | — | Vault passphrase inline. Development only — visible in `/proc//environ` and `docker inspect`. Consulted only if `SPRINGTALE_PASSPHRASE_FILE` is unset | | `SPRINGTALE_STORE__PATH` | `/data/springtale.db` | Database path inside container | | `SPRINGTALE_CRYPTO__VAULT_PATH` | `/data/vault.bin` | Vault path inside container | +| `SPRINGTALE_TRANSPORT__SOCKET_PATH` | `/data/springtale.sock` | Control socket path inside container | | `SPRINGTALE_API__BIND` | `0.0.0.0:8080` | API bind address | | `RUST_LOG` | `info` | Log level | +> `secrets/passphrase.txt` is the one file that must never be committed or +> included in a backup image. Keep it out of the build context. + ### 3.3. Verify ```bash diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c1980dd9..2e139e91 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,8 +20,8 @@ Springtale ships in five phases. Each phase builds on the last — no phase skip |---|---|---|---| | 1a | Framework + Connectors | Daemon, CLI, 14 library crates, 8 baseline connectors (kick, presearch, bluesky, github, filesystem, shell, http, opencode), SQLite (declarative schema v1 in `schema/sql/`), crypto vault, WASM sandbox, MCP endpoint | Present. Connector roster grew to 15 first-party through Phases 1b/2a. | | 1b | Bot Foundations | `springtale-bot`, command router (prefix / pattern / alias), cooperation framework, `connector-telegram`, session memory | Present. Cooperation framework extracted to its own `springtale-cooperation` crate (42 pub modules) and wired into a 25-step formation tick; see §3.2. | -| 2a | Chat + AI | Discord, Slack, IRC, Signal, Nostr connectors. Anthropic / Ollama / OpenAI-compat adapters (all three stream). `HttpTransport` (rustls mTLS). `springtale-sentinel`. Tool-calling across all AI adapters. | Present. Matrix is held on upstream `rusqlite` CVE. | -| 2b | Desktop + Safety | Tauri 2 shell, SolidJS dashboard + colony canvas (RTS formation visualisation), duress vault, panic wipe, travel mode. Visual rule builder, i18n, a11y. | Shell, dashboard, colony canvas (with formation command grid, rally pips, attention bar, liveness/health encoding), duress, panic wipe, travel mode present. Visual rule builder (`RuleBuilderOverlay`), i18n (eight locales), quick-hide, and lock-screen content protection present. a11y not implemented. | +| 2a | Chat + AI | Discord, Slack, IRC, Signal, Nostr connectors. Anthropic / Ollama / OpenAI-compat adapters (all three stream). `HttpTransport` (rustls mTLS). `springtale-sentinel`. Tool-calling across all AI adapters. | Present. No Matrix connector exists — `connectors/connector-matrix` was never written, not merely deferred; see the `members` comment in the workspace `Cargo.toml` for what bringing it back would take. | +| 2b | Desktop + Safety | Tauri 2 shell, SolidJS dashboard + colony canvas (RTS formation visualisation), duress vault, panic wipe, travel mode. Visual rule builder, i18n, a11y. | Shell, dashboard, colony canvas (with formation command grid, rally pips, attention bar, liveness/health encoding), duress, panic wipe, travel mode present. Visual rule builder (`RuleBuilderOverlay`), i18n (eight locales), quick-hide, and lock-screen content protection present. a11y present — see §5.1: skip link (`ColonyShell`), `aria-live` regions, `role="application"` + `tabindex` + per-sprite `aria-label`s on the colony canvas with keyboard selection handled at the document level, a 7 px floor on the colony text scale, and `prefers-reduced-motion` / `prefers-contrast` styles. User-controlled font scaling is the one a11y item still missing (§5.2). | | 3 | Veilid Mesh | `VeilidTransport`, P2P mesh, distributed registry, Rekindle integration | Not implemented. `VeilidTransport` exists as a stub — every method returns `TransportError::NotConnected`. | --- @@ -63,7 +63,7 @@ The foundation. A single-binary daemon, CLI, rule engine, crypto vault, WASM san - WASM sandbox with 10M instruction fuel, 64MB memory, 30s timeout - Manifest signing and verification - Capability-based permission system with toxic pair detection -- RESTful management API with HMAC bearer auth and rate limiting +- RESTful management API with issued bearer tokens (login-minted sessions + named long-lived tokens) and rate limiting - Cron scheduling + filesystem watching + webhook ingestion - MCP over Streamable HTTP at `/mcp`, covering the whole registry, behind the daemon's Origin check and bearer auth (`apps/springtaled/src/api/mcp.rs`) @@ -173,7 +173,7 @@ Broad chat platform support and optional AI integration. | `connector-slack` | Slack | Socket Mode + webhooks | Present | | `connector-nostr` | Nostr | relay WebSocket + NIP-44 | Present | | `connector-browser` | Headless browser | Chromium via CDP | Present | -| `connector-matrix` | Matrix | matrix-sdk | Not in workspace. Held on `matrix-sdk`'s pinned `rusqlite` 0.37 (CVE-2025-70873). Springtale uses the patched 0.39. | +| `connector-matrix` | Matrix | matrix-sdk | **No crate exists.** Never written, because `matrix-sdk-sqlite` pins `rusqlite` 0.37 (CVE-2025-70873) and Springtale uses the patched 0.39. Needs an upstream bump *and* a connector written from scratch. | ### 4.2. AI Integration @@ -241,9 +241,14 @@ Tauri 2 desktop shell with a SolidJS frontend that renders an RTS-inspired colon the window contents. - **i18n** — 8 locales (en, es, pt, fr, ar, th, tl, ja) via `@solid-primitives/i18n`, RTL-aware, switchable from app settings. -- **Accessibility** — skip link, `aria-live` regions on the safety panel, event ribbon, - rule builder, canvas, travel mode and sessions; screen-reader navigation over the - canvas; `prefers-reduced-motion` and high-contrast styles in `theme.css`. +- **Accessibility** — skip link (`ColonyShell`), `aria-live` regions on the safety panel, + event ribbon, rule builder, canvas, travel mode and sessions; the canvas root is + `role="application"` with `tabindex={0}` and an `aria-label`, every sprite, fuel/HP bar + and formation zone carries its own `aria-label`, and keystrokes (Escape, 1-9) are handled + at the document level rather than consumed by screen-reader navigation; a 7 px floor on + the colony text scale (`.colony-text-4xs`, scaled up from the v8 reference); + `prefers-reduced-motion` and high-contrast (`prefers-contrast: more`) styles in + `theme.css`. ### 5.2. Not Implemented diff --git a/docs/arch/ARCHITECTURE.md b/docs/arch/ARCHITECTURE.md index 9ea7ed26..96fe90f0 100644 --- a/docs/arch/ARCHITECTURE.md +++ b/docs/arch/ARCHITECTURE.md @@ -177,9 +177,13 @@ main.rs (apps/springtaled/src/main.rs) - Passphrase acquisition (`boot/crypto.rs:65-99`) has a 3-way fallback: `SPRINGTALE_PASSPHRASE_FILE` (Docker secrets), `SPRINGTALE_PASSPHRASE` (dev only), or interactive TTY prompt. Fatal if none available. -- The API token is `HMAC-SHA256(passphrase, "springtale-api-token")`. - There is no separate API key; rotating the token means rotating the - vault passphrase. +- API tokens are *issued*, not derived. `POST /auth/login` verifies the + passphrase against `HMAC-SHA256(passphrase, "springtale-api-token")` + computed once at boot, then mints a 32-byte OS-CSPRNG session token; + `POST /auth/tokens` mints named long-lived ones. Both are held only as + `sha256(token)`. That derived hash is the login *verifier* and is never + accepted as a bearer, so rotating the passphrase rotates no token — it + only changes what a future login must present. - AI adapter is hot-swappable at runtime via `ArcSwap>` (`state.rs:27`). Config changes to `/config/ai` atomically replace the active adapter with zero locking on the read path. diff --git a/docs/arch/SECURITY.md b/docs/arch/SECURITY.md index c44e67bd..36a8be07 100644 --- a/docs/arch/SECURITY.md +++ b/docs/arch/SECURITY.md @@ -260,17 +260,36 @@ Return codes: `0` = allowed, `-1` = invalid input, `-2` = denied by capability. ### 7.1 Token scheme +Tokens are **issued, never derived**. The passphrase-derived hash survives +only as the login verifier. + ``` -passphrase ──HMAC-SHA256(key=passphrase, msg="springtale-api-token")──▶ token[32B] - │ - ▼ -Request: Authorization: Bearer hex token + boot: passphrase ──HMAC-SHA256(·, "springtale-api-token")──▶ verifier[32B] + │ + POST /auth/login {passphrase} ─── constant-time compare ──────┘ + │ match + ▼ + OsRng ──▶ token[32B] ──▶ hex-encoded, returned exactly once + │ + └──▶ stored as sha256(token) + · session → process memory + · long-lived → api_tokens table + + every request: Authorization: Bearer ``` -- 32 B token, hex-encoded. -- Comparison uses `subtle::ConstantTimeEq` — timing-attack resistant. +- 32 B (256-bit) token straight from the OS CSPRNG, hex-encoded, with no + structure. Returned exactly once, in the minting response. +- Two kinds, both accepted by `require_auth`: **sessions** + (`POST /auth/login`, in-memory, idle + absolute timeouts, dropped on vault + lock or restart, revoked by `POST /auth/logout`) and **named long-lived + tokens** (`POST /auth/tokens`, rows in `api_tokens`, revoked by + `DELETE /auth/tokens/{id}`). +- Neither is ever stored in the clear — both live as `sha256(token)`. +- Comparison uses `subtle::ConstantTimeEq` — timing-attack resistant. A token + never issued, one expired, and one revoked are all the same `401`. - **SSE tickets**: `EventSource` cannot set custom headers, and the token never goes in a URL. `POST /stream/ticket` (bearer-authenticated) returns `{ "ticket": "<64 hex chars>", "ttl_secs": 30 }`; the client opens the single multiplexed `GET /stream?ticket=…` with it. Tickets are single-use and expire after 30 seconds. -- There is no separate API key. Rotating the API token requires rotating the vault passphrase. +- Rotating the vault passphrase rotates no token. It changes only what a future `POST /auth/login` must present, and a running daemon keeps the old verifier in memory until it restarts. ### 7.2 Middleware stack @@ -448,7 +467,7 @@ Sentinel checks run in this order per action: The gate sees an `ApprovalRequest` with the action, target, and reason. Its async decision is one of `Approved` / `Denied` / `Escalated` (route to another surface, e.g. push notification). -`ShellExec` is gated harder still: `crates/springtale-runtime/src/approval/` parks every ShellExec grant in a pending queue regardless of capability policy. The requestor blocks until a decision lands via `GET /approvals` + `POST /approvals/{id}` (HMAC bearer auth), the desktop approval card, or the in-app chat panel (`ChatApprovalGate`); the deny-fallback timeout (default 60s) means a dropped connection never silently grants. Decisions are recorded as `ApprovalRequested` / `ApprovalResolved` audit rows, and tool loops paused behind an approval are checkpointed (`approvals.sql`: `tool_loop_checkpoints`) and resumed after the verdict — replaying exactly the persisted bound calls, never a re-derived action. +`ShellExec` is gated harder still: `crates/springtale-runtime/src/approval/` parks every ShellExec grant in a pending queue regardless of capability policy. The requestor blocks until a decision lands via `GET /approvals` + `POST /approvals/{id}` (bearer auth), the desktop approval card, or the in-app chat panel (`ChatApprovalGate`); the deny-fallback timeout (default 60s) means a dropped connection never silently grants. Decisions are recorded as `ApprovalRequested` / `ApprovalResolved` audit rows, and tool loops paused behind an approval are checkpointed (`approvals.sql`: `tool_loop_checkpoints`) and resumed after the verdict — replaying exactly the persisted bound calls, never a re-derived action. When a bot has an AI adapter configured, the adapter is additionally wrapped in `GuardrailAdapter` (`crates/springtale-ai/src/guardrail/`): wall-clock timeout, output size cap, refusal-rate counters, and a per-bot daily token quota (`[sentinel] daily_token_limit`, persisted in `ai_token_usage`). The tool surface defaults to a zero-tool allow-list (`[bot] tool_policy`, OWASP LLM06). diff --git a/docs/current-arch/ARCHITECTURE.md b/docs/current-arch/ARCHITECTURE.md index b884409a..1c2eaf5e 100644 --- a/docs/current-arch/ARCHITECTURE.md +++ b/docs/current-arch/ARCHITECTURE.md @@ -2285,7 +2285,7 @@ lifecycle, rule evaluation, scheduler, and the management HTTP API. | Concern | Control | |---|---| -| API authentication bypass | All routes except `/health` and `/ready` require HMAC bearer token. Token derived from vault passphrase hash — no separate API key to manage. | +| API authentication bypass | All routes except `/health`, `/ready`, `/openapi.json`, `POST /auth/login` and `POST /vault/unlock` require a bearer the daemon *issued* — a session from `POST /auth/login` or a named long-lived token from `POST /auth/tokens`. Both are stored only as `sha256(token)` and matched constant-time (`subtle`). The passphrase-derived hash is the login verifier and is never accepted as a bearer. | | Webhook injection (unauthenticated) | `/webhook/{connector}/{trigger}` verifies HMAC-SHA256 signature from header before processing. Rejects if connector doesn't declare webhook support in manifest. | | Startup race conditions | Strict ordered boot (1-10 above). Each step must succeed before the next starts. API is the LAST thing to start — no requests accepted during boot. | | Management API DoS | `tower-http::limit` rate limiting: 100 req/s default. Request body size limit: 1 MiB. Timeout: 30s per request. | @@ -2432,7 +2432,7 @@ Shares the SolidJS component library with the Tauri frontend but runs standalone in any browser — for headless/remote server management. - Connector status, rule management, event log viewer - Heartbeat schedule configuration, session viewer -- No sensitive operations without HMAC bearer auth +- No sensitive operations without an issued bearer token (login session or named long-lived token) - Replaces OpenClaw's Gateway Control UI on :18789 - Bind to `127.0.0.1` by default — never `0.0.0.0` (OpenClaw's default) @@ -2469,8 +2469,8 @@ standalone in any browser — for headless/remote server management. | Concern | Control | |---|---| -| Session fixation | Stateless: HMAC bearer token per request. No server-side sessions. No cookies. Token in `Authorization` header only. | -| CSRF | No cookies = no CSRF risk. All mutation via POST/PUT/DELETE with bearer token. | +| Session fixation | Sessions are server-side but never client-fixable: the token is minted by the daemon from the OS CSPRNG at `POST /auth/login`, regenerated on every login, never accepted from the client. Held only as `sha256(token)` in process memory, expired by idle + absolute timeouts, dropped on vault lock or restart. No cookies; token in the `Authorization` header only. | +| CSRF | Not assumed away by the absence of cookies: a malicious page can POST to `127.0.0.1` regardless (Transmission CVE-2018-5702, Zoom 2019). `require_csrf_protection` rejects mutating requests carrying a non-loopback `Origin` or `Sec-Fetch-Site: cross-site`, and `Access-Control-Allow-Origin` is never sent, so a cross-origin preflight cannot succeed. | | XSS | SolidJS auto-escapes all rendered content. No `innerHTML`. CSP enforced by `springtaled` response headers. | | Clickjacking | `X-Frame-Options: DENY` and `frame-ancestors 'none'` in CSP. | | URL parameter PII | No PII in URL paths or query parameters. Rule IDs and connector names are non-sensitive identifiers. | diff --git a/docs/current-arch/SECURITY.md b/docs/current-arch/SECURITY.md index 8c957f95..eb331fd1 100644 --- a/docs/current-arch/SECURITY.md +++ b/docs/current-arch/SECURITY.md @@ -231,8 +231,8 @@ Below maps each ASVS domain to specific Springtale controls. | ASVS Domain | Springtale Controls | Verification | |---|---|---| | **V1: Architecture & Threat Modeling** | Threat model in §2.1, capability grant model §2.3, defense-in-depth (WASM sandbox + signing + capability checks). STRIDE threat model maintained as living document. | Manual review, threat model updates per release | -| **V2: Authentication** | `springtaled` management API: HMAC bearer tokens, no JWT. Tauri shell: OS-level auth for vault unlock. Duress unlock costs the same time as a real unlock, so timing does not reveal which passphrase was entered. SSE: one multiplexed `GET /stream`, opened with a one-time 30-second ticket from `POST /stream/ticket` — never a bearer token in a URL. Connector OAuth2: PKCE flow only, no implicit grant. | ZAP auth bypass scan, manual review of auth flows | -| **V3: Session Management** | Management API: stateless HMAC tokens, no server-side sessions. Dashboard: short-lived tokens, no localStorage (SolidJS stores only). | ZAP session fixation scan | +| **V2: Authentication** | `springtaled` management API: issued bearer tokens, no JWT — `POST /auth/login` verifies the passphrase against a hash computed at boot and mints a 256-bit CSPRNG session token; `POST /auth/tokens` mints named long-lived tokens. Both are stored only as `sha256(token)` and compared with `subtle`; the passphrase-derived hash is the login verifier, never a bearer. Tauri shell: OS-level auth for vault unlock. Duress unlock costs the same time as a real unlock, so timing does not reveal which passphrase was entered. SSE: one multiplexed `GET /stream`, opened with a one-time 30-second ticket from `POST /stream/ticket` — never a bearer token in a URL. Connector OAuth2: PKCE flow only, no implicit grant. | ZAP auth bypass scan, manual review of auth flows | +| **V3: Session Management** | Management API: server-side sessions, held only as `sha256(token)` in process memory, regenerated on every login (never client-supplied), expired by idle *and* absolute timeouts (`session_idle_secs` / `session_absolute_secs`), and dropped wholesale on vault lock or daemon restart. `POST /auth/logout` revokes a session; `DELETE /auth/tokens/{id}` revokes a long-lived token, effective on the next request. Dashboard: no localStorage (SolidJS stores only). | ZAP session fixation scan | | **V4: Access Control** | Capability grant model: connectors declare permissions, user approves. `ShellExec` requires blocking modal. Approval gate: an action that needs a human is put to a person through chat and the dashboard (`GET /approvals`, `POST /approvals/{id}`) instead of being silently default-denied; every destructive connector action (MCP `destructiveHint` semantics — unknown counts as destructive) goes through it. One runtime per store, enforced with an OS file lock beside the database. No privilege self-elevation from sandbox. | Manual review, fuzzing of capability enforcement in `host_api.rs` | | **V5: Input Validation** | `garde` derive-based validation on all API inputs. Connector manifest: canonical JSON with schema validation. No raw SQL (`sqlx::query_as!` only). | Semgrep rules for unvalidated input, ZAP injection scans | | **V6: Cryptography** | Ed25519 signing (`ed25519-dalek`), XChaCha20-Poly1305 AEAD (vault), Argon2id KDF, rustls-tls exclusively. No OpenSSL. No deprecated ciphers. | `cargo-geiger` for unsafe in crypto path, manual review of `expose_secret()` sites | @@ -266,7 +266,7 @@ Relevant ATT&CK techniques for an agent framework and their mitigations: | T1659 | Content Injection / Indirect Prompt Injection (Zenity) | Zenity demonstrated: malicious Google Doc → agent creates rogue Telegram integration → attacker gains C2. Impossible in Springtale: (1) connector installation requires signed manifest + user approval modal — AI adapter cannot autonomously install connectors, (2) `AiRequest` closed enum prevents raw document content from reaching AI as instructions, (3) connector output is typed and sanitized, never passed as raw prompt, (4) new connector registration requires `springtale-cli connector install` with manifest verification — no hot-loading from conversation context. | | — | Persistent Memory Poisoning (Palo Alto "stateful delayed-execution") | Palo Alto described attacks persisting in OpenClaw's Markdown memory files across sessions. Springtale mitigations: (1) memory is SQLite-backed with app-layer encryption, not plaintext Markdown, (2) memory write operations go through `springtale-bot` memory module with typed schemas — no arbitrary string injection, (3) memory compaction summarizes via AI adapter but output is validated before storage, (4) `springtale-cli memory audit` command allows users to inspect and purge memory entries. | | — | Credential Theft via Infostealer (RedLine/Lumma/Vidar/AMOS) | Infostealers specifically target `~/.openclaw/` plaintext files. Springtale mitigations: (1) all secrets in encrypted vault (XChaCha20-Poly1305 + Argon2id KDF) — no plaintext credential files, (2) `Secret` + `zeroize` ensures credentials zeroed from memory on drop, (3) vault unlock requires passphrase (not stored on disk), (4) no `~/.springtale/` directory contains any credential material in recoverable form. | -| — | WebSocket Hijack / ClawJacked (Oasis Security, CVE-2026-25253) | OpenClaw's localhost WebSocket assumed local = trusted. Springtale has no WebSocket gateway. Management API is HTTP REST with HMAC bearer tokens, `tower-http` rate limiting, and `127.0.0.1` default binding. The ClawJacked attack vector does not exist. | +| — | WebSocket Hijack / ClawJacked (Oasis Security, CVE-2026-25253) | OpenClaw's localhost WebSocket assumed local = trusted. Springtale has no WebSocket gateway. Management API is HTTP REST with issued bearer tokens, `tower-http` rate limiting, and `127.0.0.1` default binding. The ClawJacked attack vector does not exist. | | — | Default Network Exposure (135K+ instances, SecurityScorecard) | OpenClaw binds `0.0.0.0:18789` by default. Springtale: `springtaled` and `springtale-dashboard` both bind `127.0.0.1` by default. Config validation warns if binding to `0.0.0.0`. Remote access requires explicit `--bind` flag or SSH tunnel / Tailscale / VPN. | diff --git a/docs/operations/README.md b/docs/operations/README.md index 5962e84a..b3df4c80 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -26,7 +26,6 @@ read first. ├── springtale.db-wal ← WAL file (transient, will be empty after clean shutdown) ├── springtale.db-shm ← shared-memory file (transient) ├── vault.bin ← AEAD-encrypted vault (constant 131,152 bytes; two regions) -├── api_token ← HMAC bearer token for the management API ├── connectors/ ← installed connector binaries (WASM) + manifests │ └── manifest_*.toml └── audit.log ← rotating audit log (also mirrored to audit_trail table) diff --git a/docs/operations/backup-and-restore.md b/docs/operations/backup-and-restore.md index 5a4db9cf..30161579 100644 --- a/docs/operations/backup-and-restore.md +++ b/docs/operations/backup-and-restore.md @@ -15,8 +15,8 @@ in [`docs/current-arch/SECURITY.md`](../current-arch/SECURITY.md) §2.7. ``` $SPRINGTALE_DATA_DIR/ ├── vault.bin ← Ed25519 identity + connector credentials + duress region -├── springtale.db ← rules, events, formations, mental_model, audit_trail -├── api_token ← HMAC bearer token (regenerate-able) +├── springtale.db ← rules, events, formations, mental_model, audit_trail, +│ api_tokens (hashes only — see below) ├── connectors/ ← installed connector manifests + WASM binaries └── audit.log ← rolling audit log (mirrored to DB) ``` @@ -25,6 +25,36 @@ Plus the **passphrase** itself, which is *not* in the data directory. You can back up the data and have nothing useful if you don't remember the passphrase. Write it down somewhere offline. +### API credentials are not in the backup + +There is no `api_token` file, and no `api_token` vault entry. Bearer tokens +are minted by the daemon from the OS CSPRNG at login — never derived from the +passphrase, never written to disk in recoverable form. So a backup carries no +usable credential: + +- **Sessions** (`POST /auth/login`) live in process memory only, as + `sha256(token)`. Nothing to back up; nothing survives a restore, or even a + daemon restart. +- **Named long-lived tokens** (`POST /auth/tokens`) are rows in + `springtale.db` (`api_tokens`), but again only as `sha256(token)`. The rows + come back with the database, so an old token string still authenticates + *if you kept it* — the backup cannot give it back to you. + +After a restore, an operator holding the passphrase gets a working credential +by logging in: + +```bash +curl -sX POST http://127.0.0.1:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"passphrase":"..."}' +# → {"token":"","expires_in":} +``` + +If the old long-lived token strings were lost with the machine, mint +replacements with `POST /auth/tokens` and revoke the stale rows with +`DELETE /auth/tokens/{id}` — the restored hashes are still live credentials +for anyone who has the matching string. + ## What's NOT in scope - The daemon binary itself. Rebuild from source. diff --git a/docs/reference/api.md b/docs/reference/api.md index 20b12188..18b6d244 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -38,15 +38,33 @@ ## 2. Authentication -Every route except `/health`, `/ready`, and `/ui` requires a bearer token. Webhook routes (`/webhook/{connector}/{trigger}`) also require the token — the connector then performs its own signature verification on the body (HMAC-SHA256 for GitHub, RSA for Kick, etc.) inside its `verify_webhook()` implementation. +Every route except `/health`, `/ready`, `/openapi.json`, `POST /auth/login`, `POST /vault/unlock`, and `/ui` requires a bearer token. Webhook routes (`/webhook/{connector}/{trigger}`) also require the token — the connector then performs its own signature verification on the body (HMAC-SHA256 for GitHub, RSA for Kick, etc.) inside its `verify_webhook()` implementation. -The token is derived from the vault passphrase: +Bearer tokens are **issued, never derived**. `POST /auth/login` takes the +vault passphrase, compares `HMAC-SHA256(passphrase, "springtale-api-token")` +against the value the daemon computed at boot — in constant time +(`subtle::ConstantTimeEq`) — and only on a match mints a token: 32 bytes +(256 bits) straight from the OS CSPRNG, hex-encoded, with no structure at +all. That passphrase-derived hash is the login **verifier** only; it is +never accepted as a bearer. -``` -token = hex(HMAC-SHA256(passphrase, "springtale-api-token")) -``` +Two kinds of bearer exist, and `require_auth` accepts either: + +| Kind | Minted by | Lifetime | Revoked by | +|---|---|---|---| +| **Session** | `POST /auth/login` | Idle + absolute timeouts from `bot:settings` (`session_idle_secs`, `session_absolute_secs`). Held in process memory only, so a daemon restart or a vault lock drops every one. | `POST /auth/logout` | +| **Long-lived named** | `POST /auth/tokens` | Until revoked. Persisted in the `api_tokens` table. | `DELETE /auth/tokens/{id}` | + +Neither kind is ever stored in the clear: both live as `sha256(token)` — +sessions in the in-memory map, long-lived tokens in `api_tokens`. The token +string is returned exactly once, in the minting response, and nothing keeps +it. A presented bearer is hex-decoded, hashed, and looked up (sessions +first, then `api_tokens`) with a constant-time compare, so a hit and a miss +cost the same work. A token that was never issued, one that expired, and +one that was revoked are all the same answer: `401`. -Verification uses constant-time comparison (`subtle::ConstantTimeEq`). There is no separate API key — rotating the token rotates the passphrase. +`POST /auth/login` is unauthenticated by definition, so it carries its own +tight rate limit (5 req/s) on top of the global 100 req/s. Authenticated routes also go through a CSRF-protection middleware (`require_csrf_protection`) that rejects cross-origin requests with @@ -54,7 +72,11 @@ unsafe methods. SSE readers are exempt because the browser `EventSource` cannot originate a state-changing request. ```bash -# Typical client +# Log in once, then use the minted token +TOKEN=$(curl -sX POST http://127.0.0.1:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"passphrase":"..."}' | jq -r .token) + curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/connectors ``` @@ -76,13 +98,16 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/connectors Agents Authors Bot admin Sessions Memory Safety Data ┌─────────┬──────────┬──────────┬──────────┬─────────────┐ ▼ ▼ ▼ ▼ ▼ ▼ - Send Diagnostics Fixes Onboarding Templates Webhooks - ┌──────────────┬──────────────────┐ - ▼ ▼ ▼ - Approvals Chat (SSE) Dashboard (SPA) + Send Diagnostics Fixes Onboarding Recipes Webhooks + ┌──────────────┬──────────────────┬──────────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + Approvals Chat (SSE) Dashboard (SPA) Auth Vault + │ + ▼ + MCP ``` -*Fig. 3. Route groups at a glance. Public routes: health, ready, `/ui`, `/ui/*`. Everything else requires the bearer token.* +*Fig. 3. Route groups at a glance. Public routes: `/health`, `/ready`, `/openapi.json`, `POST /auth/login`, `POST /vault/unlock`, `/ui`, `/ui/*`. Everything else requires the bearer token.* ### 3.1 Health @@ -251,7 +276,7 @@ Author Ed25519 public keys used to verify signed manifests. |---|---|---| | POST | `/send` | Execute an `Action` directly against a connector. Capability-checked through the sentinel, same as rule-dispatched actions. No back door. | -### 3.15 Diagnostics, Fixes, Onboarding, Templates, Recipes +### 3.15 Diagnostics, Fixes, Onboarding, Recipes These routes back the **Doctor** and **Onboarding** flows in the desktop shell. @@ -261,10 +286,8 @@ These routes back the **Doctor** and **Onboarding** flows in the desktop shell. | GET | `/fixes` | List available auto-repair suggestions bound to diagnostic ids | | GET | `/fixes/{id}` | Fetch a single fix with its proposed action | | POST | `/fixes/{id}/apply` | Apply a fix | -| GET | `/onboarding/platforms` | List platforms that have onboarding templates (telegram, discord, github, etc.) | -| POST | `/onboarding/{platform}` | Apply an onboarding template for the given platform | -| GET | `/templates` | List rule / connector templates bundled with the daemon | -| POST | `/templates/{name}` | Write a template into the current store | +| GET | `/onboarding/platforms` | List the platform forms the first-run wizard knows. Collected at runtime from every registered connector factory that returns an `onboarding_form()` — currently **telegram, discord, slack, signal** | +| POST | `/onboarding/{platform}` | Persist a completed answer set for that platform as a connector config | | GET | `/recipes` | List curated automation recipes (browseable cookbook surface) | | GET | `/recipes/categories` | Recipe categories | | GET | `/recipes/{id}` | One recipe by id | @@ -334,6 +357,48 @@ surface through the §3.18 endpoints. | POST | `/chat` | Inject a chat message. Body `{"text": "...", "session": "optional"}` (`session` defaults to `in-app`). Fire-and-forget — returns `202 Accepted` with `{"status":"queued","session":"..."}` once queued; `400` on empty text, `503` if the bot runtime is unavailable | | GET | `/chat/stream` | SSE stream of bot replies. Each event's data is `{"session": "...", "text": "..."}` | +### 3.20 Authentication, sessions & tokens + +See §2 for the token model. `POST /auth/login` is public; everything else +in this family requires an existing bearer. + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/auth/login` | — | Verify the vault passphrase and mint a session token. Body `{"passphrase": "..."}`; returns `{"token": "", "expires_in": }`. `401` on mismatch. Rate-limited to 5 req/s on top of the global limit | +| POST | `/auth/logout` | ✓ | Drop the presented session. Returns `{"logged_out": true\|false}`. A long-lived token is not a session — revoke those with `DELETE /auth/tokens/{id}` | +| POST | `/auth/tokens` | ✓ | Mint a long-lived named token. Body `{"name": "springtale-cli@laptop"}` (1–128 chars). Returns `{"id", "name", "token"}` — the token string appears here and nowhere else, ever | +| GET | `/auth/tokens` | ✓ | List long-lived tokens: `id`, `name`, `created_at`, `last_used`. Metadata only — the hash never crosses the wire | +| DELETE | `/auth/tokens/{id}` | ✓ | Revoke a long-lived token. Revocation is immediate: the next request carrying it fails its lookup | +| POST | `/stream/ticket` | ✓ | One-time 30 s ticket for the SSE routes, bound to the presented bearer. Logging out or revoking that bearer invalidates every outstanding ticket | + +### 3.21 Vault lock & unlock + +While the vault is locked the daemon swaps in an outer router: `/health`, +`/ready`, and the two vault routes always answer, and everything else is +refused until the vault is opened. + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/vault/unlock` | — | Open the vault. Body `{"passphrase": "..."}`. Deliberately unauthenticated: while locked there is no bearer that could be presented — sessions are dropped with the process state on lock, and a long-lived token can only be looked up against that same dropped state. `Vault::open` is the check: Argon2id over the wrong passphrase fails at AEAD decryption, with no comparison to shortcut. Rate-limited per minute so the KDF cannot be driven by a flood of guesses. `409` if already unlocked, `401` on failure | +| POST | `/vault/lock` | ✓ | Lock the vault: signals the SSE streams, unwires the connector chat loops, pauses the scheduler, clears the session and stream-ticket maps, joins every background task, and zeroizes the vault key. Idempotent — locking an already-locked daemon is a `200`, so a panic-button UI never has to reason about current state. Served by the outer router, which has no `AppState`, so the bearer check is run by hand inside the handler rather than by `require_auth` | + +### 3.22 Model Context Protocol + +`springtaled` **is** the MCP server (Streamable HTTP transport). Tool calls +dispatch through the same sentinel, approval gate, and executions recorder +as a rule-dispatched action — there is no separate path. + +| Method | Path | Auth | Description | +|---|---|---|---| +| ANY | `/mcp` | ✓ | MCP Streamable HTTP endpoint | + +Two layers run in front of it, outside-in: an `Origin` check +(`require_local_origin`) rejects any non-loopback origin per the MCP +transports spec's DNS-rebinding requirement, then `require_auth` checks the +bearer, and only then is any MCP framing parsed. A missing `Origin` header +is accepted (non-browser clients do not send one); anything non-loopback is +`403`. The `Mcp-Session-Id` header is never authentication. + --- ## 4. Middleware Stack diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f8b90a60..10e0f70c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -381,14 +381,35 @@ Duress vault configured. ## 9. `springtale crypto rotate-vault-key` -Re-encrypt the vault with a new passphrase. The API bearer token changes as a side effect — the token is `HMAC-SHA256(passphrase, "springtale-api-token")`. +Re-encrypt the vault with a new passphrase. It opens the vault with the old +passphrase, copies every entry into a fresh vault created under the new one, +and rewrites the vault file. Nothing outside the vault file is touched. ``` $ springtale crypto rotate-vault-key Enter current passphrase: ******** Enter new passphrase: ******** -Vault re-encrypted. Update API clients with the new token. -``` +Vault re-encrypted. +``` + +**No API token is rotated by this.** Bearer tokens are minted from the OS +CSPRNG at login, not derived from the passphrase (see +[`api.md` §2](api.md#2-authentication)). What the new passphrase changes: + +- **The login verifier.** `POST /auth/login` compares the presented + passphrase against a hash the daemon computed once, at boot, from the + passphrase it started with. A daemon that is already running keeps that + old value in memory, so it keeps accepting the **old** passphrase at + `/auth/login` until it is restarted (or the vault locked and unlocked) + with the new one. Restart the daemon after rotating. +- **Live sessions** are unaffected while the daemon runs — they are keyed + by `sha256(token)` in process memory, with no link to the passphrase. + Locking the vault or restarting the daemon drops all of them, so every + client will have to log in again with the new passphrase. +- **Long-lived named tokens** (`POST /auth/tokens`) survive rotation + entirely: their hashes live in the `api_tokens` table, which this command + never touches. If you are rotating in response to a compromise, revoke + them explicitly with `DELETE /auth/tokens/{id}`. --- diff --git a/tauri/packages/types/openapi.json b/tauri/packages/types/openapi.json index 9cca88d9..ab9a4bd9 100644 --- a/tauri/packages/types/openapi.json +++ b/tauri/packages/types/openapi.json @@ -4449,7 +4449,8 @@ "tags": [ "workspaces" ], - "summary": "POST /workspaces/onboard?ticket=.. — SSE of `chat-discovered`\nframes (same payload as the desktop `ChatDiscovered` event) until\nthe first match, the 60 s window, or client disconnect.", + "summary": "POST /workspaces/onboard — SSE of `chat-discovered` frames (same\npayload as the desktop `ChatDiscovered` event) until the first\nmatch, the 60 s window, or client disconnect.", + "description": "This mutates (it deploys a probe through the connector), so it sits\nin the bearer + CSRF `authenticated` router, not in the stream-ticket\nrouter. The ticket exists for `EventSource`, which cannot send an\n`Authorization` header — but `EventSource` only issues GETs and this\nroute needs its connector config in a POST body, so its client was\nalways `fetch`, which can send the header. Streaming the response is\nunaffected: axum's `Sse` does not care how the request authenticated.", "operationId": "workspaces_onboard", "requestBody": { "content": { diff --git a/tauri/packages/types/src/api.ts b/tauri/packages/types/src/api.ts index 6d9e1c87..87ddf0f4 100644 --- a/tauri/packages/types/src/api.ts +++ b/tauri/packages/types/src/api.ts @@ -2241,9 +2241,16 @@ export interface paths { get?: never; put?: never; /** - * POST /workspaces/onboard?ticket=.. — SSE of `chat-discovered` - * frames (same payload as the desktop `ChatDiscovered` event) until - * the first match, the 60 s window, or client disconnect. + * POST /workspaces/onboard — SSE of `chat-discovered` frames (same + * payload as the desktop `ChatDiscovered` event) until the first + * match, the 60 s window, or client disconnect. + * @description This mutates (it deploys a probe through the connector), so it sits + * in the bearer + CSRF `authenticated` router, not in the stream-ticket + * router. The ticket exists for `EventSource`, which cannot send an + * `Authorization` header — but `EventSource` only issues GETs and this + * route needs its connector config in a POST body, so its client was + * always `fetch`, which can send the header. Streaming the response is + * unaffected: axum's `Sse` does not care how the request authenticated. */ post: operations["workspaces_onboard"]; delete?: never; diff --git a/tauri/packages/ui/src/web/api/onboard.ts b/tauri/packages/ui/src/web/api/onboard.ts index 6b584602..771c7732 100644 --- a/tauri/packages/ui/src/web/api/onboard.ts +++ b/tauri/packages/ui/src/web/api/onboard.ts @@ -1,14 +1,16 @@ /** * Track D one-click Onboard over HTTP (plan 2.5). * - * `POST /workspaces/onboard` is an SSE stream behind the one-time - * stream ticket (plan 0.7). The connector config rides in the POST - * body — never the URL — so EventSource (GET-only) can't be used; - * the frames are read from a fetch body instead. + * `POST /workspaces/onboard` answers with an SSE stream. The connector + * config rides in the POST body — never the URL — so EventSource + * (GET-only) can't be used and the frames are read from a fetch body + * instead. Because it is a fetch, it sends the normal `Authorization` + * bearer: the route is bearer + CSRF authenticated like every other + * mutating route, not stream-ticketed. */ import type { ChatDiscoveredEvent } from "../../dashboard/types"; -import { getBaseUrl, post } from "./client"; +import { getBaseUrl, getToken } from "./client"; const EVENT_NAME = "chat-discovered"; @@ -75,11 +77,13 @@ export async function startOnboardStream( const controller = new AbortController(); sessions.set(sessionId, controller); - const { ticket } = await post<{ ticket: string }>("/stream/ticket", {}); - const url = `${getBaseUrl()}/workspaces/onboard?ticket=${encodeURIComponent(ticket)}`; - const res = await fetch(url, { + const res = await fetch(`${getBaseUrl()}/workspaces/onboard`, { method: "POST", - headers: { "Content-Type": "application/json", Accept: "text/event-stream" }, + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + Authorization: `Bearer ${getToken()}`, + }, body: JSON.stringify({ session_id: sessionId, connector_name: connectorName,