Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
53c5a50
docs: correct stale auth, template, docker, and a11y claims
radicalkjax Sep 6, 2026
1d3a5e8
fix: correct stale comments in Cargo.toml, manifest types, and lock.rs
radicalkjax Sep 6, 2026
dd0500b
docs(rules): rewrite the manifest rule around the code-built manifest
radicalkjax Sep 6, 2026
5271a69
docs: retire the last two stale auth/Matrix claims in ROADMAP and rules
radicalkjax Sep 6, 2026
1246b72
security: refuse to load a WASM connector with no pinned signature
radicalkjax Sep 6, 2026
0d13c21
security: make AutoAllowApprovalGate unconstructible in production bu…
radicalkjax Sep 6, 2026
a9404fc
security: authenticate POST /workspaces/onboard like every other muta…
radicalkjax Sep 6, 2026
4f8165c
security: read action hints from one authoritative source
radicalkjax Sep 6, 2026
214d0fc
fix(momentum): classify a tick on ActionState, not on alignment
radicalkjax Sep 6, 2026
eb27aca
fix(rally): stop double-counting the failure, rally who can respond
radicalkjax Sep 6, 2026
fdcb926
fix(rally): make RallyResult::Recovered real
radicalkjax Sep 6, 2026
c18b6c0
fix(runtime): derive agent activity from the utterance ring
radicalkjax Sep 6, 2026
c5a0e63
fix(awareness): let what a peer heard survive the beat, and act on it
radicalkjax Sep 6, 2026
44e052e
docs: retire the derived-token auth model across arch and operations …
radicalkjax Sep 6, 2026
2748b4a
Merge branch 'security/verify-on-load-not-only-install' into cooperat…
radicalkjax Sep 7, 2026
5b11224
Merge branch 'docs/correct-the-stale-claims' into cooperation/momentu…
radicalkjax Sep 7, 2026
23fe761
contract: regenerate the served document after the onboard route moved
radicalkjax Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions .claude/rules/backend/connector-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Capability>` — `NetworkOutbound { host }` (exact host,
no wildcards), `FilesystemRead`/`FilesystemWrite { path }`,
`KeychainRead { key }`, `ShellExec` (blocking approval, never bypassable)
- `triggers: Vec<TriggerDecl>` — name, description, optional JSON Schema of
the event payload
- `actions: Vec<ActionDecl>` — name, description, optional input/output JSON
Schema, plus the three hints below
- `data_disclosure: Vec<DataDisclosure>` — what user data is touched, why,
and where it is sent
- `roles: Vec<RoleDecl>` — 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<bool>` — 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<u64>` — 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.
5 changes: 4 additions & 1 deletion .claude/rules/backend/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 13 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/springtale-cli/examples/task-runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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;
}
Expand Down
13 changes: 8 additions & 5 deletions apps/springtaled/src/api/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeGuard>, Json(body): Json<UnlockRequest>) -> Response {
if !guard.is_locked() {
return (
Expand Down
12 changes: 10 additions & 2 deletions apps/springtaled/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 13 additions & 4 deletions apps/springtaled/src/api/workspaces.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions apps/springtaled/tests/api_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 17 additions & 1 deletion apps/springtaled/tests/event_recipe_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions crates/springtale-bot/src/cooperation/formation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<springtale_cooperation::cadence::AgentId>,
/// 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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
Loading
Loading