mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
4a704acc02cf76a2b928befd94a1e6823895d13c
297 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9299a3b137 |
feat(ui): Sign in with Cognitum, zero-config session secret, executed JS tests
Three loose ends from the browser sign-in work. --- 1. The last mile: a button --- The server endpoints existed but nothing in ui/ linked to them, so the feature was unreachable. QuickSettings gains a "Cognitum Account" panel that renders from `GET /oauth/status` and offers Sign in / Sign out. `/oauth/status` is a new endpoint and is deliberately UNGATED — a signed-OUT browser cannot ask a gated API whether sign-in is available. It returns only capability flags and, when a session exists, who it belongs to. Never a credential. The panel distinguishes four states rather than showing a button that might 404: signed in; sign-in available; auth on but OAuth off (points at the existing static-token panel); no auth required. A 404 from /oauth/status means a server predating this work and says so plainly. Sign-in is a full-page navigation, not fetch(): the server replies 302 and the browser must follow it carrying the transaction cookie. An XHR would follow the redirect invisibly and land nowhere. --- 2. RUVIEW_SESSION_SECRET no longer required --- Previously `/oauth/start` returned 503 unless an operator invented a secret — a footgun, since they set RUVIEW_OAUTH_ISSUER, expect sign-in, and get a 503 naming an env var they have never heard of. Now resolved in order: env var, then `<data_dir>/session-secret`, then generate one and persist it 0600 (temp file, chmod before rename — the discipline used for the CLI's credentials). Persisted rather than in-memory so a restart does not silently sign everyone out. The env var still wins, which is what a multi-instance deployment needs: several servers must share a secret or a session issued by one is rejected by the next. If the file cannot be written we log the reason and continue with an in-memory secret rather than refusing sign-in outright. Verified with NO configuration at all: secret generated, file mode 0600, /oauth/start returns 302, /oauth/status reports browser_signin: true. --- 3. The JavaScript is now executed by tests --- `ui/services/ws-ticket.test.mjs`, 9 tests, node:test built-in — no new dependency and no package.json needed. Run: `node --test ui/services/`. Covers: no token means no fetch and an unchanged URL; the bearer reaches the Authorization header and NEVER the URL; `?` vs `&` when a query already exists; ticket URL-encoding; 404 treated as a pre-ADR-272 server (the property that lets one UI work against old and new servers, and therefore lets the legacy escape hatch be removed later); 503 and network failure swallowed rather than breaking the connect path; and that a fresh ticket is minted per call, since tickets are single-use and caching one fails on the second reconnect. STATED PRECISELY, because the distinction matters: this EXECUTES the module in Node with stubbed fetch/localStorage. It is more than the `node --check` it replaces and less than a browser — no real WebSocket upgrade, no real cookies, no page wiring. "The UI JavaScript has never been run" is no longer true of this module. "Browser-tested" still is not. Tests: 547 sensing-server lib, 5 wiring integration, 87 ruview-auth, 9 JS. Co-Authored-By: Ruflo & AQE |
||
|
|
347698b67c |
feat(auth): browser sign-in — /oauth/start, /oauth/callback, session cookie
Closes the gap adversarial review found: `wifi-densepose login` writes ~/.ruview/credentials.json, which a BROWSER CANNOT READ. The UI therefore had no way to obtain a Cognitum token at all, and the WebSocket ticket mechanism ADR-272 built "for browsers" was only exercisable with the legacy static shared secret OAuth was meant to replace. The ADRs described a browser story that did not exist. Ported from cognitum-one/freetokens (src/auth/oauth.ts, live at freetokens.cognitum.one), whose shape is not the obvious one and is the whole point: THE BROWSER NEVER HOLDS AN OAUTH TOKEN. The server generates the PKCE verifier and state, keeps them in an HMAC-signed cookie, performs the code exchange itself, verifies the token, and issues its OWN session cookie carrying an assertion — subject, account, scope, expiry — not a credential. So the access token cannot be read by an XSS, cannot sit in localStorage, and cannot leak through a URL. A stolen session cookie is useless against Cognitum or any sibling service. GET /oauth/start -> 302 to auth.cognitum.one + signed transaction cookie GET /oauth/callback -> constant-time state check, exchange, verify, session GET /oauth/logout -> clears the local session (not the Cognitum session) Verified against the running binary: /oauth/start returns the same 302 + HttpOnly/SameSite=Lax/Max-Age=600 shape freetokens does live; a forged `state` is refused 400; a forged session cookie is refused 401. DELIBERATE DEVIATION from freetokens: no `__Host-` cookie prefix. That prefix REQUIRES `Secure`, and RuView is routinely reached at http://localhost or over plain HTTP on a LAN, where such a cookie is never sent and sign-in would fail silently. `Secure` is set only when the request actually arrived over TLS (direct or via x-forwarded-proto). Every other attribute matches; the HMAC is what protects the value. Other decisions worth stating: - The callback verifies through the SAME `verify_access_token` every other request uses — signature, audience (client_id), typ, expiry, scope. A sign-in path must not be a softer path. - The session cookie is checked LAST in the middleware, after bearer and ticket: it is the weakest-bound credential, so a presented bearer should win. - A browser session requests `sensing:read` only. Admin work goes through the CLI's explicit `--admin`. - The token exchange runs in `spawn_blocking` — `ureq` is blocking, and parking an async worker is the mistake this codebase just had to fix in jwks.rs. - `/oauth/*` sits outside `/api/v1/*` on purpose: gating the routes you use to obtain a credential would deadlock. PKCE moved out from behind the `login` feature into its own light `pkce` feature (rand + sha2 + base64, no HTTP stack), so the server can build an authorize URL without pulling in the client-side login machinery. `login` now implies `pkce`. Tests: 13 new browser_session unit tests — signature round-trip, tampered payload, wrong secret, malformed cookie values, HttpOnly/SameSite/Secure attributes, exact scope matching with no implied escalation, a cookie name that merely ends with the target not matching, multi-scope URL encoding, and the core property that a session cookie never contains the access token. Totals: 547 sensing-server lib + 5 wiring integration, 87 ruview-auth. Co-Authored-By: Ruflo & AQE |
||
|
|
705a167ffe |
test(auth): boot the real binary and probe BOTH listeners (the wiring gap)
Two authentication bypasses shipped in this PR and 526 green unit tests could
not see either, because every auth test in the crate builds its OWN Router with
a hand-picked subset of routes. A synthetic router cannot observe how the real
one is assembled — and both defects were assembly, not logic:
1. the dedicated --ws-port listener had no `require_bearer` at all
2. `/ws/field` was .merge()d AFTER the auth layer, which in axum exempts it
This spawns the actual `sensing-server` binary (via CARGO_BIN_EXE) on ephemeral
ports and speaks raw HTTP/1.1 and real WebSocket upgrades to BOTH listeners. No
mocks, no synthetic router, no in-process shortcuts.
PROVEN TO HAVE TEETH, which matters more than it passing: with main.rs reverted
to
|
||
|
|
b09625ece7 |
fix(auth): close the four residual findings from the adversarial review
(a) Test fixture still invented an `iss` claim.
`bearer_auth.rs`'s `token_with_scope` added `"iss": ISSUER` to its tokens.
Harmless today because the verifier ignores `iss` — but it is the same
fixture-invents-reality pattern that hid the original `iss` bug for a day,
sitting in the second-largest auth test suite. Removed, with a pointer to
ruview-auth's regression test.
(b) Cross-process refresh race -> session revocation.
The single-flight guarantee was per-PROCESS. Every CLI invocation is a new
process with its own Session and mutex over one shared credential file, so
two commands run close together inside the 60s refresh window would each
present the same rotating refresh token — and the second is replay, which
identity answers by revoking the whole session family. The user gets logged
out for running two commands at once.
Now guarded by an advisory file lock, taken NON-BLOCKING. A busy lock means
another process is already refreshing, so we wait and re-read its result
rather than race it (20 x 150ms, then proceed anyway — the lock is advisory,
not a correctness barrier, and a dead holder must not wedge us). Blocking on
the lock would have parked the async executor, which is the exact mistake
just fixed in jwks.rs.
Unix only. On other platforms it is a documented no-op — a lock that does
nothing while claiming to protect is worse than none.
(c) One principal could exhaust the global ticket pool.
The 512 cap was global with no per-caller quota, so a single authenticated
`sensing:read` client looping on POST /api/v1/ws-ticket could hold every
slot for 30s and 503 everyone else — denial of service by the
lowest-privilege account the product issues. Added a 16-ticket
per-principal cap; a page needs a handful. Test asserts a noisy user hits
its own cap while a second user is still served and the global pool is
never exhausted.
(d) Debug impls printed live credentials.
`AuthState` derived Debug over the raw RUVIEW_API_TOKEN and
`StoredCredentials` over both OAuth tokens. Not leaking today — I checked
every call site — but this PR had already hand-written redacting Debug for
`OAuthState` and `TicketStore` for exactly this reason, and the two types
actually holding secrets were the ones that missed out. Both now redact.
Tests: 87 ruview-auth (2 new: lock exclusivity + non-blocking, Debug
redaction), 534 sensing-server (1 new: per-principal quota).
Co-Authored-By: Ruflo & AQE
|
||
|
|
0547fd7344 |
fix(auth): enforce client_id as the audience — Cognitum's stand-in for aud
Found by reading cognitum-one/freetokens, a live sibling service whose browser OAuth landed while this PR was open. Its integration contract states the platform rule outright: "Cognitum access tokens intentionally use custom `client_id` rather than a registered JWT `aud` claim." -- freetokens docs/AUTH_INTEGRATION.md and `src/auth/oauth.ts` enforces it on every sign-in: payload.client_id !== config.OAUTH_CLIENT_ID -> reject RuView did not. An earlier revision here removed the `client_id` check and kept it only for logging, reasoning that clients borrow one another's registrations (musica shipped as `meta-proxy` while its own was pending) and that scope alone must therefore carry the boundary. That reasoned from a TRANSITIONAL state: RuView has its own registered client (identity migration 0017), and the platform does have an audience mechanism — it is simply spelled `client_id`. Consequence of the old behaviour: a Cognitum access token minted for ANY product — meta-proxy, musica, metaharness, freetokens — was accepted by a RuView server provided it carried a sensing scope. Scope was the only thing standing between another product's token and this one. Now there are two boundaries, audience and capability, which is what the platform intends. - `VerifierConfig.allowed_client_ids`; empty = accept any (explicit opt-out). - `RUVIEW_OAUTH_CLIENT_IDS` env, default `ruview`, `*` to disable with a loud warning naming what is being given up. Comma-separated for the migration case where a borrowed registration must be accepted alongside our own. - New `VerifyError::WrongAudience`, checked BEFORE scope, so the failure names the real reason rather than blaming the scope. The existing cross-product test now asserts `WrongAudience` rather than `MissingScope` — the token is refused for the stronger reason. Three new tests: a correctly-scoped token from another product is still refused; the empty-list opt-out accepts anything (pinned so it stays deliberate); multiple allowed clients work. This also corrects the module docs and ADR-271, which claimed "scope is the ONLY capability boundary" — true of the code as written, but not of the platform. Tests: 85 ruview-auth, 533 sensing-server. Co-Authored-By: Ruflo & AQE |
||
|
|
f67a880a1a |
docs: correct three doc-vs-code contradictions found by adversarial review
All three are my own drift — the exact failure this session criticised in other
repos' ADRs and then reproduced.
1. ADR-271 §2 still listed "`iss` matches the configured issuer verbatim" in the
accept-rule. That rule was removed from the code in
|
||
|
|
7a05417493 |
fix(auth): scope gate was fail-OPEN; JWKS lock held across a blocking fetch
Two charges from an adversarial multi-vendor review (qe-court). Both verified
in source before fixing.
--- 1. AUTHORIZATION BYPASS: POST /api/v1/adaptive/train reachable with read ---
`required_scope_for` enumerated admin routes by prefix (`/api/v1/train/`, plus
DELETE on models/recording) and let EVERYTHING ELSE fall through to
`sensing:read`.
`POST /api/v1/adaptive/train` (main.rs:8112 -> handler main.rs:5028) calls
`adaptive_classifier::train_from_recordings()`, writes the model to disk with
`model.save()`, and swaps `state.adaptive_model` used by the live inference
pipeline. It does not start with `/api/v1/train/`, so it landed on
`sensing:read` — the scope `wifi-densepose login` requests BY DEFAULT. Exactly
the blast radius the read/admin split exists to prevent, reachable by the
lowest-privilege token the product issues.
Fixed by inverting the polarity, which is the real defect — a denylist for a
security gate keeps missing routes as routes keep being added:
GET/HEAD/OPTIONS -> sensing:read
any other method -> sensing:admin
...unless the exact path is in READ_SAFE_MUTATIONS (an explicit allowlist of
mutations that change runtime state but destroy nothing: ws-ticket,
model load/unload/activate, calibration start/stop, recording start/stop,
vendor event ingest)
A mutating route added tomorrow is now admin-gated by default. Pinned by
`an_unknown_mutating_route_defaults_to_admin`. `POST /api/v1/ws-ticket` is
allowlisted deliberately and has its own test: were it admin, the read scope
could never open a stream from a browser at all.
Enumerated all 17 mutating /api/v1 routes to build the allowlist rather than
guessing. `POST /api/v1/config/ground-truth` now requires admin — a deliberate
tightening, it writes config.
--- 2. DoS: blocking JWKS fetch under std::sync::Mutex in async middleware ---
Filed INDEPENDENTLY by two prosecutors, which is why it gets fixed rather than
argued about.
`decoding_key_for` locked a `std::sync::Mutex` and held it across a blocking
`ureq` call (3s timeout, longer on a dead link), invoked from inside the async
`require_bearer`. `Mutex::lock()` in an async fn is a blocking syscall, not a
yield point — so one slow JWKS fetch blocked EVERY concurrent request on that
mutex, including ones carrying already-cached valid tokens, and parked the
tokio workers running them. On Pi-class hardware with few workers that stalls
the whole server. It fires on the routine 300s TTL rollover whenever the link
is degraded — the exact offline case the module exists to tolerate. Reachable
by anyone able to send a syntactically valid ES256 header with an unknown kid,
since kid lookup precedes signature verification.
Now three phases: read under the lock, RELEASE, network, re-take only to
install. Cost is a possible duplicated idempotent GET during a rollover, which
is strictly better than serialising every request behind one socket. The
codebase already uses spawn_blocking for outbound I/O elsewhere
(main.rs:2490, 5272); moving verification fully onto spawn_blocking remains a
follow-up — this removes the amplification, not every blocking millisecond.
Tests: 533 sensing-server (7 new scope-polarity), 82 ruview-auth.
Co-Authored-By: Ruflo & AQE
|
||
|
|
3347e258e6 |
fix(ws): gate the dedicated WS port and the merged field routes (ADR-272 was incomplete)
The previous commit claimed WebSocket upgrades were gated. They were not. Found
by an adversarial cross-vendor review, reproduced, and fixed here.
MEASURED BEFORE (auth ON via RUVIEW_API_TOKEN, no credential presented):
HTTP port :3991 /ws/sensing -> 401 (what the previous fix covered)
HTTP port :3991 /ws/field -> 101 HOLE
WS port :3990 /ws/sensing -> 101 HOLE
WS port :3990 /ws/field -> 101 HOLE
control :3991 /api/v1/models -> 401
Two independent defects, both from the same root cause — routes registered
AFTER an axum `.layer()` are silently exempt from it:
1. The dedicated WebSocket server on `--ws-port` was built with ONLY
`host_validation::require_allowed_host`. `require_bearer` was never applied
to it at all. My earlier verification only ever probed the HTTP port and I
generalised from it.
This is the worse of the two, because it is the port the UI actually uses:
`ui/services/sensing.service.js` maps HTTP 8080 -> WS 8765. So the previous
fix protected a path the browser never takes, while the path it does take
stayed open.
2. On the HTTP router, `/ws/field` (ADR-262) was `.merge()`d AFTER the
`require_bearer` layer, so it bypassed authentication entirely. The auth
layer is now applied after the merge, and the comment says why the ordering
is load-bearing.
MEASURED AFTER, same conditions:
:3989 /ws/sensing -> 401 · :3989 /ws/field -> 401
:3988 /ws/sensing -> 401 · :3988 /ws/field -> 401
with bearer on the upgrade: 101 on both ports
ticket minted at POST /api/v1/ws-ticket on the HTTP port and redeemed on the
WS port: 101 (AuthState shares its TicketStore via Arc)
auth OFF: 101 — unchanged, no regression
526 sensing-server tests still pass.
TEST GAP, stated rather than papered over: no automated test covers this. The
defect is in router WIRING in main.rs, not in logic a unit test reaches — both
holes were invisible to 526 green tests and to the ws_gate_tests suite, which
builds its own Router and therefore cannot see how the real one is assembled.
Catching this class needs an integration test that boots the binary and probes
BOTH ports; that is the honest follow-up.
Co-Authored-By: Ruflo & AQE
|
||
|
|
eb68e07a2c |
feat(ui): fetch WebSocket tickets, prefix-match WS paths, and write ADR-272
Completes ADR-272. Three parts. 1. PREFIX MATCHING, not an allowlist. Anything under `/ws/` is a WebSocket path, plus `/api/v1/stream/pose` which lives outside it. An allowlist means every WebSocket route added later ships ungated until someone remembers to extend it — the same bug reintroduced on a delay. Not hypothetical: `/ws/train/progress` (ADR-186, arriving with PR #1387) is ALREADY referenced by ui/services/training.service.js and would have shipped unauthenticated. Pinned by a test that asserts it is gated before it exists. 2. UI wiring. A shared `withWsTicket()` helper mints a ticket immediately before each connection attempt — never cached, because a ticket is single-use and expires in seconds, so reusing one across reconnects fails on the second attempt. Wired into the three sites that open gated sockets: sensing.service.js, websocket-client.js, observatory/js/main.js. It degrades in both directions on purpose: no stored token means auth is off and no ticket is needed; a 404 from /api/v1/ws-ticket means a server predating this ADR, which still exempts WebSockets, so connecting without a ticket is correct there. The same UI therefore works against old and new servers, which is what makes the escape hatch removable later rather than permanent. The long-lived bearer token is still never put in a URL — only the ticket is. 3. ADR-272 itself. Previously cited in five places without existing; the same dangling-reference mistake made with ADR-271 earlier today, so it is written before this lands rather than after someone notices. It records the measured before/after, why a credential in a URL is acceptable here specifically (single use, seconds-long, not the credential), why the escape hatch exists and why it is deliberately uncomfortable, and what is deliberately NOT done — including that /health/metrics stays ungated, with the caveat that this should be revisited if metrics ever carry occupancy-derived values, since that would make them sensing data wearing an ops label. Tests: 526 sensing-server (4 new path-matching), 82 ruview-auth. JS syntax-checked with `node --check`; there is no UI test suite to extend. Co-Authored-By: Ruflo & AQE |
||
|
|
6300b1cbd2 |
feat(ws): gate WebSocket upgrades behind bearer-or-ticket (ADR-272)
Closes the hole measured in
|
||
|
|
7d6d66941a |
feat(ws): single-use WebSocket ticket store (ADR-272 groundwork)
Audit finding this exists to close — verified empirically, not inferred. With RUVIEW_API_TOKEN set (operator believes auth is ON), a real WebSocket handshake carrying NO credential: /ws/sensing -> 101 Switching Protocols /ws/introspection -> 101 Switching Protocols /api/v1/stream/pose -> 101 Switching Protocols /api/v1/models -> 401 (control: REST is correctly gated) The REST control plane is locked while the DATA plane — live presence, pose and vitals — is open to anyone who can reach the port. Two of those paths sit outside PROTECTED_PREFIX entirely; the third is the documented EXEMPT_PATHS entry. The exemption was reasonable when added (a browser cannot set Authorization on an upgrade) but its blast radius is larger than it looks, and phase 3 sharpened the contrast by making REST genuinely strong. (To be precise about what was proven: the handshake is accepted. A payload frame was not captured in that window, so this is "the connection is established without a credential", not "data was read".) This commit adds only the store; wiring it into the middleware is a breaking change for browser clients and lands with the UI update. Design notes: - Single use. `consume` REMOVES the entry, so a replay of the same URL fails even inside the TTL. This is what makes a credential-in-a-query tolerable: by the time it reaches an access log or a Referer header, it is spent. - 30-second TTL. Long enough for a page to open a socket; too short to harvest. - It is not the credential. It authorizes one WebSocket. It cannot be replayed against /api/v1/*, cannot be refreshed, and carries no reusable identity. - The grant captures the ISSUING principal's scopes, so a WebSocket inherits exactly the authority of the credential that asked for it — a sensing:read session cannot mint a ticket that outranks itself. - Capped at 512 outstanding, self-healing as tickets expire, so an authenticated but misbehaving caller cannot grow the map without bound. - In-memory because that is correct, not merely convenient: a ticket surviving a restart would outlive the server that vouched for it. Native clients (Python, Rust CLI, TS MCP) are NOT browsers and will send a normal Authorization header on the upgrade instead — tickets would add a round-trip and a second credential path for no benefit. 12 tests: single-use enforced, replay refused, expiry refused AND pruned, unknown ticket refused, 256-bit unpredictability, grant carries issuer scopes, cap enforced and self-healing, and query parsing including `?myticket=x` not being read as `?ticket=x`. Co-Authored-By: Ruflo & AQE |
||
|
|
6d3fb88677 |
fix(cli): resolve scope from the token on read, and add whoami --refresh
Two gaps that only appear with a credential file written by an earlier build —
i.e. exactly the case a fresh-login test never exercises.
1. `whoami` printed "Scope: (not reported)" for an existing file. The previous
commit resolved scope from the token claim at WRITE time only, so files
written before it stayed blank forever. `effective_scope()` now falls back at
READ time, which fixes existing files and any client that stored only what
the token response carried. The token is authoritative either way; the stored
field is a convenience copy.
2. `whoami` said the token "will refresh on next use" — a promise nothing kept,
because no command called `ensure_fresh`. The refresh path was implemented
and unit-tested but never actually run. `--refresh` exercises it, and goes
through `Session::ensure_fresh` rather than reimplementing a second, subtly
different refresh, so it inherits the single-flight guarantee and the
persist-before-return ordering.
It is a flag, not silent behaviour: refreshing rotates the stored refresh
token — identity spends the old one — so it is a state change, not a read.
VERIFIED AGAINST PRODUCTION, end to end:
whoami on a pre-existing file -> Scope: sensing:read (was "not reported")
whoami --refresh -> both tokens rotated
refresh sha256[:12] 50cfa06b -> 2d37617a
access sha256[:12] 61eebe8d -> 14d8e8de
status: expired -> valid
refreshed token GET /api/v1/models -> 200
refreshed token POST /api/v1/train/start -> 401 (still read-scoped)
That exercises the rotating-refresh path against identity's real reuse
detection — the one place a bug costs the user their session rather than a
retry — and confirms the scope gate survives a refresh.
Tests: 82 with --features login, 44 default, 501 sensing-server.
Co-Authored-By: Ruflo & AQE
|
||
|
|
12635a85b2 |
fix(auth): stop requiring an iss claim Cognitum never issues (G-3 blocker)
The verifier called `Validation::set_issuer` and listed `iss` in `set_required_spec_claims`. Cognitum access tokens have **no `iss` claim** — the real claim set is typ, sub, account_id, org_id, workspace_id, client_id, scope, family_id, jti, iat, exp, setup, workload. So the verifier rejected every genuine token with a flat 401. The whole 41-test suite was green throughout, because the test fixtures included an `iss` the real thing does not have. The tests validated my assumption instead of the platform. Found only by pointing a real token at a real server; no amount of additional unit testing against the same wrong fixture would have caught it. `valid_claims()` now mirrors production exactly, with a comment saying why, so the next person cannot reintroduce the drift by "fixing" an incomplete-looking fixture. What binds a token to its issuer, then: the JWKS. We accept only signatures made by a key served from the configured jwks_uri, so a valid signature IS proof of issuer. meta-llm's verifier — the org's only other resource-side verifier — checks neither `iss` nor `aud`, for the same reason. `VerifierConfig.issuer` stays, now documented as JWKS-derivation and logging rather than a claim assertion. Three tests replace the two that asserted issuer behaviour that cannot exist: a real-shaped (iss-less) token verifies; an unrelated `iss` changes nothing (so identity adding one later cannot silently start failing tokens); and a token signed by a different key is still refused — the complement that proves removing the issuer check did not remove the boundary. Also: report the granted scope from the token's `scope` claim when the /oauth/token envelope omits it, which identity's does. `login` and `whoami` said "(not reported by the server)" while the authoritative answer sat inside the token they had just stored. The claim-peek helper is display-only and documented at length as NOT verification — a client reading its own freshly issued token is a different situation from a server reading a stranger's. VERIFIED END TO END against production (gate G-3, previously open): no credential GET /api/v1/models -> 401 garbage bearer GET /api/v1/models -> 401 real sensing:read token GET /api/v1/models -> 200 real sensing:read token GET /api/v1/recording/list-> 200 real sensing:read token POST /api/v1/train/start -> 401 real sensing:read token POST /api/v1/train/stop -> 401 Token obtained by a human through `wifi-densepose login` against auth.cognitum.one; server ran with RUVIEW_OAUTH_ISSUER set, JWKS fetched live at boot (key_count=1). Tests: 82 with --features login (58 unit + 22 matrix + 2 doctests). Co-Authored-By: Ruflo & AQE |
||
|
|
31fb3d53f6 |
feat(cli): wifi-densepose login — Cognitum sign-in (ADR-271 phase 2)
Phase 1 could verify a token and phase 3 could gate on one, but there was no way for a user to OBTAIN one. This closes that: sign in with a Cognitum account and get a token a RuView sensing server accepts, instead of everyone sharing one static RUVIEW_API_TOKEN string. Lives in `ruview-auth` behind a non-default `login` feature rather than in the CLI, so the Tauri desktop app can reuse it instead of growing a second copy. A server built with default features still gets the verifier and nothing else — no reqwest, no tokio net, no browser launcher. (This amends ADR-271's "no login flow in this crate" note; the reason for that line was to keep the server lean, and a feature gate achieves it without duplication.) Ported from meta-proxy's src/oauth/, cross-checked against musica's cognitum_provider.rs — two independent implementations against this same AS. Where they agree, this follows both: redirect path EXACTLY /oauth/callback, 60-second refresh skew, OOB fallback on SSH/CONTAINER//.dockerenv. Refresh is the part with teeth. Identity rotates refresh tokens with reuse detection, so presenting a spent one revokes the whole session family. Both obvious implementations are wrong: refreshing concurrently looks like replay, and retrying a failed refresh with the same token IS the replay. So `Session::ensure_fresh` holds an async mutex across the await, re-checks expiry after acquiring it (the waiter usually finds the work already done), persists the rotated token BEFORE returning it, and never retries. A missing expires_at counts as expired rather than being given a guessed default. Least scope by default: `login` requests `sensing:read`. `--admin` adds `sensing:admin` explicitly, and requests both because there is no scope hierarchy server-side. A session that streams poses should not casually hold the capability to delete the model it streams through. Credentials are written atomically and 0600 (temp file, chmod BEFORE rename) — the same discipline the seed applies to its cloud key. `logout` is local-only and says so: it makes this machine unable to act as you, but revoking the session everywhere is an account-level action. Also `whoami`, which reports whether the stored token is live — an expired-looking session is the most common reason a command starts 401ing, and it should be visible directly rather than inferred from a failure elsewhere. Verified against PRODUCTION, not just locally: authorize URLs built by this exact code path return HTTP 200 from auth.cognitum.one for both `sensing:read` and `sensing:read sensing:admin`, which exercises the real client_id, scope encoding, PKCE parameters and redirect_uri shape. Tests: 74 with --features login (51 unit + 21 verifier matrix + 2 doctests), including the RFC 7636 Appendix B vector, multi-scope URL encoding (a space that is hand-formatted rather than encoded silently truncates the request), a real TCP callback round-trip, callback timeout, 0600 permissions asserted on disk, atomic-save leaving no temp file, and refresh-window boundaries. Unchanged: 43 with default features, 501 in the sensing server. Co-Authored-By: Ruflo & AQE |
||
|
|
c2bd33e649 |
feat(sensing-server): accept Cognitum OAuth on /api/v1/*, scope-gated (ADR-271 phase 3)
Wires `ruview-auth` into `bearer_auth.rs`. `RUVIEW_OAUTH_ISSUER` enables it;
unset, nothing changes.
Layering, in order:
1. `RUVIEW_API_TOKEN` set and the bearer matches exactly -> allow. Byte-for-
byte today's behaviour.
2. Otherwise, if OAuth is configured, verify the bearer as a Cognitum access
token and require the scope the route needs.
3. Otherwise 401.
The static compare goes first for compatibility, not security: a matching
static token is not a JWT and a JWT never matches the static token. It means an
existing deployment behaves identically even with OAuth switched on.
Scope gate (`required_scope_for`), split by blast radius per ADR-060 —
"can this destroy something", not how many routes it covers:
sensing:admin /api/v1/train/* (hours of Pi CPU, writes models)
DELETE /api/v1/models/{id} (irreversible)
DELETE /api/v1/recording/{id} (irreversible)
sensing:read everything else
Deliberately NOT admin: model load/unload and recording start. They mutate
server state but destroy nothing, and gating them would push routine dashboard
use into requesting delete capability — the opposite of least privilege.
The legacy static token stays un-scope-gated. It predates scopes and carries no
claims, so narrowing it would be a silent breaking change to deployments using
it; migrating to OAuth is how an operator opts into the finer split.
FAIL CLOSED at boot. If OAuth is requested but cannot work — empty issuer, or a
JWKS we cannot fetch — the server logs why and exits rather than serving.
Starting anyway would silently downgrade an operator who asked for OAuth to
either an open API or a shared-secret one, with no signal it happened. The JWKS
is warmed eagerly for the same reason: a bad `jwks_uri` should die at boot with
a legible message, not surface as a puzzling 401 an hour later.
The verified `Principal` is attached to request extensions, so handlers and
audit logs can attribute a request (`sub`, `account_id`, `org_id`,
`workspace_id`, `jti`) instead of knowing only "someone had the secret". That
is the point of moving off a shared bearer.
Verification failures are logged with the reason and returned as a flat 401 —
the reason is useful to an operator and equally useful to an attacker probing
for which claim to forge next.
Also aligns `ruview_auth::extract_bearer` to match the scheme
case-insensitively (RFC 7235 §2.1). The sensing server has always done this
deliberately, with a comment saying why; the two layers disagreeing about what
a valid header looks like would be a latent bug.
Tests: 16 new in `bearer_auth::oauth_tests`, driving a real Router end to end
(request -> middleware -> verifier -> handler) with ES256 tokens signed by a
runtime-generated key. Covers the scope policy as a pure function, read-scoped
tokens refused on delete and train, admin-scoped tokens allowed, an
`inference`-only token from another Cognitum product refused on every route,
garbage and absent bearers, both legacy-token layering directions, the
principal reaching a handler, and the unset case remaining a no-op.
`cargo test -p wifi-densepose-sensing-server --lib --no-default-features`:
501 passed, 0 failed. `ruview-auth`: 43 passed across both feature configs.
Co-Authored-By: Ruflo & AQE
|
||
|
|
499cec7914 |
feat(auth): ruview-auth — offline Cognitum OAuth access-token verification (ADR-271)
RuView's `/api/v1/*` is gated today by `RUVIEW_API_TOKEN`: one shared secret, no expiry, no per-user attribution. This adds the verifier half of replacing that with Cognitum identity — RuView as an OAuth **resource server**, so a user signs in to their own sensing server with their Cognitum account. Note the direction: RuView does not call Cognitum. It never obtains a token for its own use; it verifies tokens users present. Every other Cognitum OAuth integration in the org (meta-proxy, musica, metaharness, the dashboard CLI) is the client side of a plane RuView doesn't have. The one existing resource-server verifier is `meta-llm/src/auth/oauthBearer.ts`, and this crate is a port of its accept-rule — divergence would be a bug, not a preference: a token meta-llm rejects must not be one RuView accepts. Verification is OFFLINE, and that is a requirement rather than an optimisation. RuView runs on Pi-class hardware that loses WAN, and identity publishes no introspection endpoint even when the network is up. So: ES256 over identity's published JWKS, cached by `kid`. What it accepts, mirroring oauthBearer.ts: typ == "access" AND NOT setup AND NOT workload AND account_id non-empty AND exp in the future AND iss matches verbatim AND the required scope is held. Long-lived setup (365-day) and workload credentials are refused outright for identity's own stated reason: their revocation lives in `oauth_setup_tokens`, and RuView — like meta-llm — has no database to check it. A 15-minute access token needs no revocation round-trip because it expires faster than revocation propagates; a 365-day one does. Scope is the capability boundary, and it has to be. Cognitum access tokens carry no `aud`, and `client_id` cannot substitute because clients borrow each other's registrations (musica ships DEFAULT_CLIENT_ID = "meta-proxy"). `sensing:read` covers streams and inference; `sensing:admin` covers training, model delete and recording delete. No hierarchy — admin does not imply read; consent means what it said. Registered in identity migration 0016 (cognitum-one/dashboard#116). Design notes: - `ureq`, not `reqwest`: the sensing server deliberately chose ureq as "the smallest" HTTP client, and pulling reqwest in here would reverse that for the whole graph. Transport sits behind a `JwksFetcher` trait, so a host can supply its own and take no HTTP dependency at all (feature `ureq-transport`, default). - A JWKS refetch failure is survivable while a key set is already cached: a key that verified a minute ago has not stopped being valid because the network blipped, and failing closed there logs every user out of their own sensing server whenever their internet wobbles. We fail closed in exactly one case — no key set has ever been fetched. - Unknown `kid` forces one refetch so rotation is picked up without waiting out the TTL, rate-limited so junk-kid tokens can't become a request amplifier aimed at identity. - Expiry is reported distinctly from a bad signature: on an RTC-less Pi that is usually a clock-sync fault, and an operator must be able to tell them apart. - Test keypairs are generated at runtime, never committed. This repo tracks zero `.pem` files and committed key material shouldn't start here — it also means no fixture can drift out of sync with the JWKS it's served by. Tests: 41 green (19 unit + 21 matrix + 1 doctest) under BOTH `cargo test --no-default-features` (the repo's canonical gate) and default features. The matrix signs real ES256 tokens rather than asserting on strings, and covers alg:none, forged signature, spliced payload, unknown kid, expired, leeway boundaries both sides, wrong issuer, issuer differing only by a trailing slash, missing/!=access typ, setup and workload smuggled onto typ=access, missing and empty account_id, and scope escalation. The highest-value case is `g2_a_genuinely_valid_token_from_another_cognitum_ product_cannot_reach_the_sensing_surface`: a correctly signed, unexpired, right-issuer, right-typ token with client_id=meta-proxy and scope=inference is rejected. Nothing about its signature or identity claims distinguishes it — only scope does. A naive verifier accepts it, and an `inference` token becomes a key to someone's home sensor. No wiring into the sensing server yet; this crate is verification only, with no login flow and no outbound Cognitum calls. Co-Authored-By: Ruflo & AQE |
||
|
|
6ee1b55896 | feat: implement ADR-270 vendor provider beta (#1360) | ||
|
|
76c80c33d7 | feat(hardware): add Qualcomm CSI simulator and vendor roadmap (#1359) | ||
|
|
232b1c79f6 | feat(hardware): add MediaTek Filogic CSI simulator (#1358) | ||
|
|
8a5af5dad4 |
feat: add RTL8720F Realtek radar beta support (#1356)
* docs(adr): plan RTL8720F radar SDK integration * feat(hardware): add Rust RTL8720F radar simulator * feat(ruview): ingest RTL8720F radar frames |
||
|
|
8c0bdaef92 | fix open issue release blockers | ||
|
|
1692b16f6e |
fix(sensing-server): canonicalize calibration frames to 56-tone grid (real HT40 fix)
Follow-up to the calibration deadlock fix. With the status gate unstuck, maybe_feed_calibration reached feed_calibration, but a real ESP32 HT40 node streams 128-wide amplitude frames while the single-link FieldModel is the canonical 56-tone grid. LinkStats::update returned DimensionMismatch, feed_calibration bubbled it, and maybe_feed_calibration swallowed it at debug level — so frame_count stayed pinned at 0 on live hardware (presence/vitals, which read the global history, were unaffected). Resample each frame onto the model's canonical 56-tone grid via HardwareNormalizer::resample_to_canonical before feeding — the same length-only canonicalization the multistatic fusion path uses (#1170). Pinned by maybe_feed_calibration_resamples_wide_frames_and_accumulates (128-wide -> Collecting + count 1). sensing-server bin: 173 passed, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
4bab48e15f |
fix(sensing-server): unstick empty-room field-model calibration deadlock
POST /api/v1/calibration/start created the FieldModel in Uncalibrated, but
field_bridge::maybe_feed_calibration only fed frames while already Collecting
— and the only thing that sets Collecting is feed_calibration on its first
fed frame. The two gates deadlocked: no first frame was ever fed, so
calibration_frame_count stayed 0 and status never left Uncalibrated. Observed
live on a streaming ESP32 node as {"status":"Uncalibrated","frame_count":0}
that never advanced.
- field_bridge::maybe_feed_calibration: feed while Uncalibrated | Collecting
so the first frame flips the model to Collecting and the count advances.
- calibration_stop: return structured {success:false, frame_count,
frames_needed} instead of an opaque 500 when finalized with too few frames.
- FieldModel::min_calibration_frames() accessor for the guard above.
- Regression test: maybe_feed_calibration_advances_uncalibrated_to_collecting.
Presence/motion/vitals were unaffected (separate auto rolling baseline).
Co-Authored-By: claude-flow <ruv@ruv.net>
|
||
|
|
82c1b8fdf8 |
chore: bump wifi-densepose-signal 0.3.5 for crates.io (#1334)
Published 0.3.4 predates HardwareNormalizer::resample_to_canonical and MultistaticConfig::for_tdm_schedule, which the sensing-server binary uses — its publish verify fails against the registry 0.3.4. The in-repo version had not been bumped since those APIs landed. Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
9dceb976c7 |
chore(publish): version rufield deps + bump worldgraph/rufield submodules (#1334)
- wifi-densepose-rufield: add version="0.1.0" to the four rufield path deps — rufield-core/-provenance/-privacy/-fusion are now published to crates.io, making this crate (and wifi-densepose-sensing-server 0.3.4) publishable - v2/crates/worldgraph -> 4441bc0: wifi-densepose-worldgraph 0.3.2 published (adds prune_semantic_states; unblocks wifi-densepose-engine 0.3.1 publish) - vendor/rufield -> f3c1492: breaks the fusion<->adapters circular dev-dependency (path-only dev-dep, stripped at publish) Closes the crates.io publish blockers in #1334. Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
8c1d3d772a |
chore: bump wifi-densepose-engine 0.3.1, wifi-densepose-sensing-server 0.3.4
engine 0.3.1: additive StreamingEngine::set_multistatic_config (#1312) sensing-server 0.3.4: bearer-auth pose-WS exemption + EngineBridge guard config threading (#1312, #1313); no public lib API change (engine_bridge is binary-internal) Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
83f549d308 |
fix: post-review fixes for PRs #1311/#1312/#1313 + CHANGELOG
- sdkconfig.defaults.devkitc: header build command idf v5.2 -> v5.4 (source needs esp_driver_uart, IDF >=5.3 — same reason the PR fixed the README refs) - engine/lib.rs: separate doc comments — set_room_adapter's ADR-150 adapter/witness doc had merged into set_multistatic_config's doc - ui: apply stored bearer token at api.service.js module load instead of QuickSettings.init — services + dashboard tab dispatch their first /api/v1/* requests before initializeEnhancements() runs, so the first requests on every load went out without the Authorization header - CHANGELOG: Unreleased entries for #1308/#1309/#1310 Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
d59ca00baa | Merge PR #1313: exempt /api/v1/stream/pose WS from bearer auth + UI token field | ||
|
|
2ddb6a7b02 |
fix(sensing-server): exempt /api/v1/stream/pose WS from bearer auth; add UI token field
Browsers cannot attach an Authorization header to a WebSocket upgrade, so with RUVIEW_API_TOKEN set the Live Demo pose stream at /api/v1/stream/pose always failed with 401 — the same reason /ws/sensing is already exempted (see bearer_auth module docs). Adds a narrow EXEMPT_PATHS list plus a regression test that the exemption does not leak to other /api/v1/* paths. Query-string tokens remain rejected (CWE-598 test untouched). Also adds an 'API Access' bearer-token field to the QuickSettings panel: ui/services/api.service.js had setAuthToken() but nothing ever called it, so enabling RUVIEW_API_TOKEN broke every /api/v1/* call from the bundled dashboard. The token is stored in localStorage and applied before the first request. Fixes #1310 Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
b5ce60081b |
fix(sensing-server): wire WDP_GUARD_INTERVAL_US/WDP_TDM_* into EngineBridge
EngineBridge::new built its own StreamingEngine, whose internal MultistaticFuser was hardcoded to MultistaticConfig::default() (60 ms guard) — the #1031/#1049 env overrides only reached the sibling multistatic_fuser field on AppState, so governed trust cycles failed against the default guard no matter what the deployment configured. - wifi-densepose-engine: add StreamingEngine::set_multistatic_config() - engine_bridge: EngineBridge::new takes Option<MultistaticConfig> - main.rs: thread the same env-derived config into both fusion paths Verified on a 2-node ESP32-S3 deployment: the failure message now reflects the configured guard, and with healthy nodes governed cycles ran 90 s with zero fusion errors. Fixes #1309 Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
fca5e6f0a0 |
fix: multistatic canonicalization, csi_fps burst inflation, control-packet starvation (#1170, #1180, #1183) (#1193)
#1170 — live multistatic bridge fed raw, un-canonicalized per-node CSI (64/128/192 bins) to MultistaticFuser, tripping DimensionMismatch every cycle and silently disabling fusion on mixed HT20/HT40 meshes. Add HardwareNormalizer::resample_to_canonical (resample-only, no z-score) and canonicalize every node frame onto the 56-tone grid before fusion. #1180 — update_csi_fps_ema only rejected dt<=0 or >=1s, so sub-ms UDP-burst arrivals (36us -> ~27kHz) inflated csi_fps_ema 40-840x. Add a 5ms plausibility floor and stop re-anchoring observe_csi_frame_arrival on burst deltas. #1183 — global ENOMEM backoff (CSI flood) starved <=48B/<=1Hz control packets. Add stream_sender_send_priority() bypassing the backoff gate without touching the streak; route feature_state/HEALTH/sync through it. Fix the misleading "HEALTH sent" log that printed even on rv_mesh_send failure. Verified: signal 501, sensing-server 677 tests (0 failed); firmware builds clean. Claude-Session: https://claude.ai/code/session_01AgpTcBLRJ32hUsKWxDXf36 |
||
|
|
315d7df09e |
chore(deps): bump ruv-neural submodule — ColorMap no_std for ESP32 (#1126)
Points to ruvnet/ruv-neural#3 (c9638fa): ruv-neural-viz::ColorMap now builds no_std, so it can run on the ESP32. Unblocks driving the onboard WS2812 from the viridis/cool-warm colormap. |
||
|
|
bdd1eaf927 |
chore: untrack ruvector.db runtime artifacts + gitignore at any depth (#1124)
These are hook/runtime-generated databases (ruvector/intelligence store) that kept showing as dirty and don't belong in version control. Removed from the index (files kept on disk) and ignored globally. |
||
|
|
c6e7667676 |
Merge pull request #1104 from ruvnet/fix/issue-1049-configurable-guard
fix(sensing-server): make multistatic guard interval configurable (closes #1049) |
||
|
|
c84ea39e62 |
chore: bump ruv-neural submodule → current main (web console, closed-loop neuromod, ruvn mention)
Advances the vendored ruv-neural submodule from the stale 'Initial' pin (1ece3af) to current main (81be9e1): the static web console UI, the closed-loop neuromodulation platform, repositioned README, and the @ruvnet/ruvn companion-tool mention. ruv-neural is not a v2 workspace member, so this does not affect the workspace build (cargo metadata resolves clean). Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
9c751d0d92 |
chore(worldgraph): bump submodule — README + metadata polish
Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
a13e9b66cb |
chore: bump ruv-drone + worldgraph submodules (LICENSE + CI polish)
Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
6db183bf3e |
chore(swarm): bump ruv-drone submodule — README cleanup
Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
f65d0f79e7 |
chore(swarm): bump ruv-drone submodule (rescope + stray-file cleanup)
Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
7fb3b88061 |
chore(swarm): bump ruv-drone submodule — industrial rescope (drop ITAR/USML gating)
Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
aeac5f5543 |
chore(worldgraph): extract geo+worldgraph+worldmodel to ruvnet/worldgraph submodule
- published as github.com/ruvnet/worldgraph (3-crate workspace, history via git-filter-repo) - replace the 3 in-tree crates with one submodule at v2/crates/worldgraph - parent workspace: drop the 3 members, exclude the submodule (it is its own workspace), repoint workspace.dependencies(worldmodel) + engine/sensing-server path-deps into it - cargo metadata resolves clean (geo/worldgraph/worldmodel consumed from the submodule) Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
c257e67c3d |
chore(swarm): extract ruview-swarm to ruvnet/ruv-drone submodule
- ruview-swarm published as github.com/ruvnet/ruv-drone (history preserved via subtree split) - replace the in-tree crate with a submodule at v2/crates/ruview-swarm (branch main) - standalone repo dropped the unused wifi-densepose-core path-dep; export-control NOTICE added there - workspace member path unchanged; cargo metadata resolves ruview-swarm from the submodule Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
c27d6cc98e |
fix(sensing-server): make multistatic guard interval operator-configurable (#1049)
Two ESP32-S3 nodes on WiFi/ESP-NOW sync drift 10-150 ms (~70 ms typ.), exceeding the 60 ms default guard → permanent trust demotion to Restricted, all pose output suppressed, 200k+ errors, no escape but a container restart. Add a direct WDP_GUARD_INTERVAL_US override (+ optional WDP_SOFT_GUARD_US) to multistatic_guard_config_from_env. Precedence (most-specific wins): direct override > WDP_TDM_SLOTS+WDP_TDM_SLOT_US schedule-derived > 60ms/20ms default. Soft band always clamped strictly below hard; malformed/zero ignored (falls back, never breaks fusion). Effective guard logged at startup. Pinned by 6 tests (multistatic_guard_config_tests). sensing-server bin tests 449 -> 455, 0 failed. Python proof PASS, hash unchanged (off signal path). Closes #1049. Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
cafbeb1e81 |
fix(wasm-edge): sanitize non-finite host floats at the WASM↔host frame boundary (#1102)
Closing beyond-SOTA security review of wifi-densepose-wasm-edge (ADR-040,
~70 edge modules). The two WASM↔host boundaries (lib.rs::on_frame/on_timer
and bin/ghost_hunter.rs::on_frame) read raw IEEE-754 f32 from the csi_get_*
imports with no finiteness check — the crate had zero is_finite/is_nan
guards and its clamp helpers propagate NaN. A single non-finite host value
latches NaN into long-lived per-module accumulators (EMA / Welford / phasor
sums / anomaly baselines), after which detectors fail degraded (stuck gate
state, silently-disabled checks) — silent corruption, not a crash.
Add sanitize_host_f32() (non-finite -> 0.0, core-only for no_std) applied at
every host_get_* float read: one chokepoint covering all downstream modules,
mirroring the existing M-01 negative-n_subcarriers boundary clamp. LOW /
defense-in-depth (the Tier-2 DSP firmware supplies the imports, a semi-trusted
boundary).
Pinned by boundary_tests::{sanitize_passes_finite_values_through,
sanitize_maps_non_finite_to_zero,
coherence_monitor_nan_latches_without_sanitize_but_not_with} — the last
asserts on the current CoherenceMonitor that a raw NaN frame latches the
smoothed score while the sanitized path stays finite.
Other review dimensions attested clean with evidence (see CHANGELOG): no
hot-path panics (all unwrap/expect are test-only or std-gated RVF builder),
all bounds min()-clamped, all index-by-cast const-bounded or guarded, no
leaking closures (no move||/forget/leak), no secrets.
Verified: host `cargo test --features std,medical-experimental` 672 passed /
0 failed (+3 new tests); all three wasm32-unknown-unknown release artifacts
build clean (lib default no_std/panic=abort, ghost_hunter standalone-bin,
medical-experimental); Python proof VERDICT PASS, hash unchanged.
|
||
|
|
c859f6f743 |
security(occworld-candle): int32-checkpoint crash + degenerate-input guards + ADR-179 (closes Milestone #9) (#1101)
* fix(occworld-candle): security review fixes — int32 checkpoint crash + predict input validation Beyond-SOTA security + correctness review of wifi-densepose-occworld-candle (Milestone #9, crate 4/4 — the last ungated crate). Findings fixed: 1. HIGH (MEASURED) — checkpoint-load crash on any int32 tensor. model.rs mapped safetensors I32 -> candle DType::I64 and passed the raw int32 byte buffer (4 bytes/elem) to Tensor::from_raw_buffer(.., I64, ..). Candle derives elem_count = data.len() / dtype.size(), so the I64 path halved the count while keeping the original shape -> a tensor whose shape claims 2x its storage. Reading it PANICS (slice OOB: "range end index 6 out of range for slice of length 3") on any checkpoint containing an int32 tensor. Fixed: I32 -> DType::I32, I16 -> DType::I16 (both first-class candle dtypes). Reproduced on old code; pinned in tests/checkpoint_loading.rs. 2. LOW (MEASURED) — predict() lacked frame/batch validation at the input boundary. f_in > num_frames*2 over-indexed the temporal embedding (cryptic candle "gather" error); zero frame/batch fed a zero-element tensor in. Now rejected with a clear ShapeMismatch. Pinned in tests/input_validation.rs. 3. LOW (MEASURED) — divide-by-zero panic in the public VQCodebook::encode on a rank-0 / empty-last-dim tensor (last == 0). Now fails closed with a clear error. Pinned in vqvae.rs unit tests. Dimensions confirmed clean with evidence: panic surface (no unwrap/expect/ panic in prod paths), NaN-state-poisoning (N/A — stateless engine, u8 input), unbounded-alloc/shape-data mismatch (defended upstream by safetensors:: validate), secrets (none). unsafe_code = forbid. Validation (MEASURED, Windows): crate 31/31 pass; workspace 0 failed (lone desktop api_integration "Access is denied" file-lock flake passes 21/21 in isolation); Python proof VERDICT PASS, hash f8e76f21…446f7a unchanged. Warrants ADR slot 179 (parent to author). Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr): ADR-179 — occworld-candle checkpoint-load hardening (closes Milestone #9) Records the HIGH int32-checkpoint crash fix (I32→I64 dtype-widening → slice-OOB panic on load = DoS) + 2 LOW degenerate-input fixes from 5e77f47e5. Stateless engine (NaN-poisoning N/A), unsafe forbidden, safetensors validate() defends malloc upstream. occworld 31/31. Final ungated crate — Milestone #9 complete. Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
10c813fde3 |
security(desktop): IPC serial-command-injection + over-broad shell capability + ADR-178 (#1100)
* fix(security): desktop IPC serial-command-injection + over-broad shell capability (ADR-178) Beyond-SOTA security review of wifi-densepose-desktop (Tauri v2). Two real findings, each MEASURED on Windows (crate builds + tests under --no-default-features): WDP-DESK-01 (MODERATE) — serial command injection via configure_esp32_wifi. The #[tauri::command] handler concatenated webview-supplied ssid/password into newline-terminated serial commands with no validation; a \r\n let a compromised webview inject an arbitrary follow-up firmware command (reboot/erase). Added validate_wifi_credentials() enforcing WPA2 length bounds and rejecting all control characters, called fail-closed before any serial write. Pinned by 3 new tests (rejects \r\n / \n / NUL injection, rejects out-of-range, accepts valid boundaries). WDP-DESK-02 (MODERATE) — removed unused shell:allow-execute / shell:allow-open from capabilities/default.json. The Rust backend spawns processes via std::process::Command (bypassing the allowlist) and the UI only uses dialog.open; the shell perms were unused privilege granting the webview arbitrary host command execution on compromise. Regenerated capabilities.json confirms only core:default + dialog perms remain. lib tests 18 -> 21 (+3 pins), integration 21 -> 21, 0 failed. Python deterministic proof unchanged (f8e76f21...46f7a; desktop off the signal path). Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr): ADR-178 — desktop IPC injection fix + capability least-privilege Records the 2 MEASURED MODERATE fixes in feddcde9d: WDP-DESK-01 (webview ssid/password \r\n-injected arbitrary firmware serial commands → validated fail-closed) and WDP-DESK-02 (unused shell:allow-execute/open capability granted to the webview → removed). 30-command IPC surface + capability scope audited; 6 dimensions clean-with-evidence. desktop 18→21. Co-Authored-By: claude-flow <ruv@ruv.net> |
||
|
|
20ad75f30c |
feat(ADR-131): HOMECORE-UI dashboard + BFF gateway — review-fixed (supersedes #1082) (#1099)
* feat(ADR-131): HOMECORE-UI operational dashboard + BFF gateway Complete two-tier Cognitum operator dashboard (ADR-131), served by homecore-server at /homecore, plus the single-origin BFF gateway that wires it to real backends. Front-end (zero-dep vanilla TS/JS + CSS, exact Cognitum design tokens): - All 10 panels (§4.1-4.10): dashboard, SEED fleet + detail, fleet map, entities (live WS subscribe_events, never polls), rooms, COGs, calibration wizard, events + automation builder, witness/audit, settings. - §6 UX invariants in code: first-class provenance, prominent stale/veto/ fragility, null(not-trained) vs withheld vs error, --mono everywhere, Hailo vs CPU COG distinction. - api.js calls the gateway routes in production; mock demoted to a dev-only ?demo=1 fixture (no mock in prod); typed error states. - Tests under plain node: import-graph, boot, render-smoke (22), interaction (3), prod-errors (13) — 5 files green; bundle ~137 KB (~37x smaller than HA), <2 ms/cold-render. BFF gateway (homecore-server/src/gateway.rs, compiled + tested on Rust 1.89): - /api/cal/* reverse-proxy to the calibration API (ADR-151). - GET /api/homecore/rooms with the RoomState adapter (breathing->breathing_bpm, heartbeat:null->heart_bpm:null, injected anomaly.threshold/room_id). - GET /api/homecore/cogs supervisor over /var/lib/cognitum/apps/. - GET /api/homecore/appliance from /proc + TCP service probes. - SEED-device/appliance routes return typed 503 upstream_unavailable. - cargo test -p homecore-server = 12/12; run live (curl-verified); fixed a real double-v1 proxy-URL bug found during live testing. Honest scope: W1/W2/W4/W6-appliance functional; W3/W5/W6-Hailo/federation return typed 503 (depend on services/hardware not in this repo). Co-Authored-By: claude-flow <ruv@ruv.net> * fix(homecore-ui): resolve code-review findings — SSRF guard, CORS/trace coverage, §6 honesty, crash guards Addresses the high-effort review of PR #1082: - SECURITY: cal_proxy rejects path-traversal/confused-deputy SSRF (`.`/`..` segments, backslash, %2e%2e/%2f, absolute) on raw+decoded forms → 400, before attaching the server-side calibration bearer. - CORRECTNESS: /api/homecore/* + /api/cal/* now covered by the shared CORS allowlist (build_cors_layer, exported from homecore-api) + TraceLayer — previously merged outside router()'s layers (no CORS, no tracing). - §6 HONESTY (no fabricated data): dashboard renders '—' for null metrics (not "null%"/"null°C"); cogs Hailo pill reflects the REAL appliance probe (not hardcoded "connected"); room anomaly threshold passed through / null, not a fabricated 0.5. - ROBUSTNESS: cogs asArray(hef) guards a non-array manifest field; calibration progress guards target<=0 (no NaN%/Infinity%); restart clears the poll timer. - CLEANUP: mock.js is now a cached DYNAMIC import (demo-only) — never bundled in production (§2.2). - New ui/tests/unit-fixes.mjs pins the above; ADR-131 + CHANGELOG updated. Co-Authored-By: claude-flow <ruv@ruv.net> --------- Co-authored-by: Nick Ruest <127058086+nicholas-ruest@users.noreply.github.com> |
||
|
|
1df6d1e1ee |
security(nvsim): guard degenerate input — config panic + NaN silent-corruption + ADR-177 (#1098)
* fix(nvsim): guard degenerate input — config-induced panic + NaN-state poisoning
Beyond-SOTA security review of the ADR-089 NV-diamond simulator (milestone #9,
crate 2 of 4). Two real degenerate-input findings, each pinned fails-on-old:
NVSIM-DT-01 (config panic/DoS, pipeline.rs): an external f_s_hz == 0 made
dt == +Inf, dt_us saturated to u64::MAX, and `sample * dt_us` panicked with
"attempt to multiply with overflow" at sample >= 2 (debug/WASM panic=abort;
garbage t_us in release). Fix: sanitise dt (non-finite/non-positive -> 1 µs
fallback), cap the u64 cast, and saturating_mul the timestamp.
NVSIM-NAN-01 (NaN-state poisoning, digitiser.rs): a non-finite scene parameter
(NaN dipole position / Inf moment / NaN loop radius) bypasses the near-field
clamp (NaN < R_MIN_M is false) and yields a NaN field; at the ADC `NaN as i32`
== 0 silently emitted b_pt=[0,0,0] with ADC_SATURATED CLEAR — indistinguishable
from a legit zero-field reading. Fix at the funnel: adc_quantise treats any
non-finite input as out-of-range -> clamps to code 0 AND raises the saturation
flag, so the corruption is visible downstream.
Determinism integrity, panic-free MagFrame deserialisation, and RNG seeding
confirmed clean with evidence. The published cross-machine witness
(cc8de9b0…93b4) is unchanged — guards only affect degenerate inputs.
cargo test -p nvsim --no-default-features: 50 -> 53 passed, 0 failed.
Workspace green; Python deterministic proof unchanged (f8e76f21…46f7a,
nvsim off the signal proof path). Needs ADR slot 177.
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr): ADR-177 — nvsim degenerate-input hardening
Records the 2 MEASURED MEDIUM fixes in
|
||
|
|
4a083999e5 |
security(ruview-swarm): fail-closed on NaN/Inf at the swarm-comm trust boundary + ADR-176 (#1096)
* fix(ruview-swarm): fail-closed on NaN/Inf at swarm-comm trust boundary (ADR-148)
Beyond-SOTA security review of the ADR-148 drone swarm control plane found
four IEEE-754 NaN/Inf fail-open / DoS bugs on data crossing the untrusted
swarm-comm boundary (receive_peer_state / receive_peer_detection accept full
DroneState/CsiDetection whose f64/f32 fields deserialize with no finite-check).
- HIGH: failsafe::tick collision-avoidance + battery checks fail-open on NaN
(NaN < threshold == false silently disabled collision avoidance / kept a
NaN-battery drone Nominal). Now fails closed to EmergencyDiverge / RTH.
- MED: geofence::check NaN-altitude bypass returned Safe through the
point-in-polygon path. Now leading non-finite-coordinate guard -> HardBreach.
- MED/DoS: antijamming FhssRadio panicked with "% 0" on an empty deserialized
channels_mhz. Now len==0 early-returns (benign 0.0 sentinel).
- LOW: multiview::fuse propagated a NaN victim_position into the fused
"confirmed victim" location. Now requires finite confidence + position.
Each fix pinned by a fails-on-old / passes-on-new test (MEASURED: old code
returned Nominal/Safe or panicked). cargo test -p ruview-swarm
--no-default-features: 117 -> 123 passed, 0 failed. Workspace green; Python
deterministic proof unchanged (f8e76f21...46f7a, off the signal path).
Documented-not-fixed (ADR slot 176): Raft AppendEntries lacks Log-Matching
consistency check (topology/raft.rs); MavlinkSigner::verify uses non-constant
-time tag compare + no replay-window rejection (already doc-flagged).
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr): ADR-176 — ruview-swarm NaN-fail-open safety review
Records the 4 MEASURED fail-open safety bugs fixed in
|
||
|
|
0f64d23516 |
feat(bench): int8 quantization of WiFlow-STD half pose model — MEASURED trade-off (ADR-175, honest negative) (#1095)
Sub-deliverable 8.2 of the benchmark/optimization milestone. Quantizes the 843,834-param "half" WiFlow-STD pose model (half_best.pth) to int8 two ways and MEASURES the accuracy/size trade-off vs fp32 under ONE locked normalization (ADR-173 torso-diameter PCK, upstream calculate_pck use_torso_norm=True), on the same seed-42 file-level 70/15/15 test split that produced the fp32 sweep numbers. MEASURED on ruvultra (RTX 5080, torch 2.11.0+cu128, fbgemm; clean test, torso-PCK): fp32 96.62% pck@20 99.47% pck@50 0.008981 mpjpe 3.351 MB int8 PTQ static 40.98% pck@20 94.98% pck@50 0.038262 mpjpe 1.046 MB (-55.64pp) int8 QAT (3 ep) 67.48% pck@20 98.69% pck@50 0.026548 mpjpe 1.043 MB (-29.15pp) Verdict (honest no): int8 is NOT a win at the strict PCK@20 edge target. Static PTQ collapses; QAT recovers a large share but still loses 29 pp @20 for a 3.2x size win — keep fp32/fp16 on the edge. Disclosed: QAT fake-quant val pck@20 was 83.45% but converted int8 scores 67.48% (~16pp convert_fx gap, reported honestly). Deliverables: - v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py (reproducible: header carries the exact ssh command + run date; QAT primary, static PTQ fallback) - docs/adr/ADR-175-int8-quantization-half-pose-model-measured.md (MEASURED table, locked normalization, QAT-vs-PTQ labeling, verdict, reproduction, limitations) - CHANGELOG [Unreleased] ### Added entry No production Rust or signal-pipeline change. Python deterministic proof unchanged (f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a, bit-exact). |