Make deployment consume the exact published sensing-server image, publish the two Python packages in lock-step, and enforce the production witness gate.
Decision: browser-side admin is not wanted. `BROWSER_SIGNIN_SCOPE` stays
`sensing:read`, and the escalate-on-demand design sketched while this was open
is not being built. Destructive operations — training, model delete, recording
delete — keep their home in the CLI, where `--admin` is explicit and typed by a
person. Routing them through a browser would mean either asking every user to
consent to delete capability in order to watch a stream, or building a second
consent flow to avoid that.
Consequences, now settled rather than open:
- The UI's admin controls are unreachable from a Cognitum browser session, and
that is intended. The manual token-paste field is unchanged and still carries
whatever authority the pasted token has, so nothing that worked before stops
working.
- REMOVED the client-side step-up redirect from ui/services/api.service.js. It
caught an RFC 6750 challenge that can never be issued to a browser, and it
ended in `return new Promise(() => {})` — so if any other 401 had ever grown
that header, every caller awaiting it would have hung forever with no error
and no timeout. Dead code with a trap in it is worse than no code.
- KEPT ADMIN_REVERIFY_SECS as a server-side backstop. Fail-closed and free, so
if the requested scope is ever widened the freshness requirement is already
in place. Documented at its definition as a backstop specifically so nobody
reads its passing tests as evidence the control is exercised — the tests
reach it through a crate-internal seam that mints an admin cookie the real
flow does not produce.
ADR-271 also stops hedging on the session TTL: chosen is A at one hour. Option B
(server-side refresh-token store) is not built, and the ADR now names the
residual instead of implying it is closed — within one hour a revoked Cognitum
grant still reads sensing data through an existing browser session. There is no
introspection endpoint, so nothing short of B closes that, and one hour is the
size of the hole we accepted.
Adds `the_cookie_max_age_matches_the_session_expiry`, which the previous ADR
revision asked for and nobody had written: Max-Age and the payload's `exp` are
two independent expressions of one lifetime, and drift means either the browser
presents a session we reject or we hold authority the browser discarded.
Verified: workspace 176 suites clean, UI 22, api.service.js parses.
Co-Authored-By: Ruflo & AQE
A cross-vendor pre-merge sweep found no merge blockers, but did surface an
error in ADR-271 that I wrote — and a coherence gap behind it.
ADR-271 P2 stated that capping the browser session at `sensing:read` was
"considered and rejected, because the dashboard genuinely performs admin
operations". That is wrong. `/oauth/start` (main.rs:9206) already requests
`SENSING_READ` and nothing else, deliberately, with a comment saying so. The
browser session is ALREADY read-only, so the breakage I claimed capping would
cause is simply the current behaviour.
Two consequences, now stated in the ADR instead of left to be discovered:
1. The UI's admin controls do not work from a browser OAuth session.
`model.service.js:136` issues DELETE /api/v1/models/{id}, which 401s. Admin
work needs the CLI (`login --admin`) or a pasted admin bearer. A gap in a new
feature, not a regression — the token-paste path is unchanged.
2. The ADMIN_REVERIFY_SECS step-up control added in the previous commit guards
a case that cannot currently arise. No browser session holds `sensing:admin`,
so the freshness branch never fires in production. Its tests pass because the
crate-internal seam mints an admin cookie the real flow never produces.
That second point is worth being blunt about: it is the same shape as several
defects this branch already fixed — correct code, green tests, unreachable call
site. The difference is that here the guard is deliberately ahead of the need
rather than mistakenly behind it, and saying which one it is matters.
So the constant is now named `BROWSER_SIGNIN_SCOPE` rather than inlined, with
the cost of widening it documented at the definition, and two tests:
`browser_sign_in_stays_read_only_until_someone_decides_otherwise` pins the
value, and `the_authorize_url_actually_carries_that_scope` proves it reaches the
wire — asserting on the constant alone would pass even if `begin` were called
with something else, which is exactly the isolation failure being guarded
against.
The ADR also records the coherent way to add browser-side admin if wanted:
escalate-on-demand via the RFC 6750 challenge, keeping least privilege by
default rather than asking every user to consent to delete capability to watch
a stream. Not bundled here — it needs a scope parameter and a UI affordance.
Sweep verdict: no merge blockers. Two other non-blocking risks it raised are
accurate and unchanged: concurrent JWKS refresh at the stale boundary is not
atomic (a duplicated idempotent GET, already documented as an accepted cost),
and the service worker's SHELL_ASSETS use root-relative paths while the UI
mounts under /ui, so offline shell precaching is incomplete — pre-existing,
unrelated to this branch.
Verified: workspace 176 suites clean.
Co-Authored-By: Ruflo & AQE
All three deferred findings from the qe-court round. Each fix is guarded by a
test confirmed to FAIL against the old behaviour.
P1 — JWKS: self-inflicted stall, and a blocking fetch on a tokio worker.
`fetched_at` advances only on SUCCESS, and the only rate limiter sat behind
`if fresh`. So once the TTL elapsed after the last successful fetch, `fresh`
was permanently false, the limiter was never consulted, and EVERY request
performed its own blocking 3s-timeout fetch. A Pi that loses WAN stalled
itself 300s later with no attacker present; an attacker could force the same
state by flooding tokens with an unknown `kid`.
Now: `last_attempt_at` is recorded BEFORE every fetch regardless of outcome,
and gates the stale path too; a stale-but-present key is served rather than
erroring, which is the offline tolerance this module always claimed.
Measured by the new test: 26 outbound fetches before, 1 after.
Kept as TWO independent limiters. Merging them looks tidy and is wrong — a
routine refetch would then suppress the unknown-`kid` path for 30s and delay
pickup of a key rotation inside the TTL. I made that mistake first; two
existing tests caught it.
The blocking call also now runs in `spawn_blocking` at the verify boundary,
matching what `main.rs` already does for the token exchange, where the comment
reads "the same mistake this codebase had to fix in jwks.rs". The hot
verification path had never been given the same treatment. A panicked task
fails closed.
P2 — session lifetime, per decision: 1 hour, plus step-up.
SESSION_TTL_SECS 12h -> 1h, and privileged (`sensing:admin`) actions now
require the user to have authenticated within ADMIN_REVERIFY_SECS (5 min),
tracked by a new `auth_time` claim. Reads ride the full session; only the
routes where a stale session does damage are re-verified, so a dashboard whose
main use is watching a live stream does not re-auth hourly.
`auth_time` is `#[serde(default)]`, so a cookie issued before the field existed
reads as 0 — infinitely stale. Such a session keeps working for reads and
cannot perform privileged actions. Fail-closed and self-healing on next sign-in.
The refusal carries an RFC 6750 `WWW-Authenticate` error code, because the
client's correct response differs from a plain 401: the user IS signed in and
needs to prove it again. `api.service.js` acts on that and redirects through
`/oauth/start` — otherwise a stale-session delete surfaces as a generic
"Request failed" with no hint that signing in again fixes it.
P3 — cookie shadowing.
`read_cookie` returned the FIRST match, and RFC 6265 §5.4 sends longer-`Path`
cookies first. Cookies are not isolated by port or scheme, so any other service
on the host — or a plain-HTTP MITM injecting Set-Cookie — could plant
`ruview_session=<their own validly signed session>; Path=/ui`. The victim sent
both, the attacker's first, and it verified because it genuinely was signed:
silent session takeover, with `/oauth/status` reporting the attacker's account.
The signature was doing its job throughout, which is why "it's signed" never
answered this. `__Host-` would, but requires `Secure`, and RuView is routinely
reached over plain HTTP on a LAN.
So both credential paths now accept only when EXACTLY ONE candidate verifies.
An attacker can still cause a refusal by planting a second valid cookie — a
nuisance — but no longer a takeover. Planting junk changes nothing, so this
does not become a trivial DoS.
Tests: +1 jwks (26-vs-1 fetch amplification), +4 step-up, +4 shadowing, +1
duplicate-name reader. Mutation-verified: reverting the stale-path guard gives
26 fetches; reverting to first-match cookie reads fails
`a_shadowing_cookie_cannot_silently_take_over_the_session`.
Verified: workspace 176 suites clean under CI flags, ruview-auth 62+25+2 with
--all-features, UI 22.
ADR-271: P1/P2/P3 marked RESOLVED with the analysis retained, since it explains
why each fix has the shape it does.
Co-Authored-By: Ruflo & AQE
Browser sign-in was the newest security surface in this PR and had no
executable evidence behind it: browser_session.rs was 534 lines with 13 tests,
every one of which hit a private helper (sign, unsign, cookie, read_cookie,
is_live, has_scope). No test called issue, from_cookie_header, begin,
verifier_for_callback or is_configured, and no test anywhere presented a session
cookie to the gate.
+10 tests in browser_session, +6 in bearer_auth. Three mutants the adversarial
review named, each now verified dead by actually applying the mutation:
(a) delete the `state` comparison in verifier_for_callback
-> a_callback_whose_state_does_not_match_is_refused FAILED
Without it the callback accepts a code from a flow the user never
started: login CSRF, victim silently lands in the attacker's session.
(b) `session.is_live().then_some(session)` -> `Some(session)`
-> an_expired_session_cookie_does_not_authenticate FAILED
-> an_expired_browser_session_is_refused FAILED
`is_live` was already unit-tested; nothing asserted the CALLER consults
it. Same "tested in isolation, call site untested" shape as the earlier
refresh-never-invoked defect.
(c) `session.has_scope(required)` -> `true`
-> a_read_scoped_browser_session_cannot_delete_or_train FAILED
Without it any browser session could delete models and start training.
Mutant (c) initially appeared to SURVIVE. It did not — there are two
has_scope call sites and the first substitution only hit one. Mutating the
one in `session_or_unauthorized` kills the test. That accident confirmed a
separate finding: the cookie branch inside require_bearer is unreachable when
an Authorization header is present, because the OAuth step returns on both
arms. It fails closed, so it is not a hole, but "try the next credential" is
what the code reads like. Pinned by
a_bad_bearer_beats_a_good_cookie_rather_than_falling_back.
Adds two crate-internal test seams (init_secret_for_tests, test_cookie_value).
test_cookie_value signs through the same path as `issue`, so tests presenting a
cookie exercise real verification rather than a test-only bypass.
ADR-271:
- The "browser cannot obtain an OAuth token" section asserted
`grep -ril "oauth|cognitum|pkce" ui/` returns nothing. It now returns three
files, invalidated by commits in this same PR. Marked superseded, original
retained under a fold, replaced with what actually ships.
- Records the two deferred decisions with designs rather than patches: P1 the
blocking JWKS fetch on a tokio worker (whose rate limiter is bypassed on
exactly the stale path that matters, because fetched_at updates only on
success — so after the TTL every request fetches, and a Pi that loses WAN
stalls itself with no attacker present); P2 the 12-hour session from a
15-minute token, with three costed options. Capping the session to
sensing:read was considered and rejected: the dashboard genuinely issues
DELETE /api/v1/models/{id}.
- P3 records that dropping `__Host-` costs origin-integrity, not just Secure —
read_cookie takes the FIRST match and cookies are not port-scoped, so a
same-host writer can shadow a session. Forgery was never the threat that
prefix addresses.
- Documents redirect_uri's hardcoded default and the unconsumed CLI credential
as known-incomplete, per decision to leave both as-is.
ADR-272: corrected a claim that would mislead users into a 401. It stated the
Python client DOES send Authorization: Bearer on the handshake; ws.py passes no
headers at all (zero occurrences of extra_headers or Authorization), so every
published client 401s once auth is enabled. Server-side decision unchanged.
Verified: sensing-server 566 + 179 + 7 + 5 + 8 + 4 + 16 pass under CI flags,
ruview-auth 61 + 25 + 2 with --all-features, auth_wiring 7.
Co-Authored-By: Ruflo & AQE
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 12635a85 because Cognitum
issues no `iss` and it rejected every real token. The ADR's own "Facts about
the tokens" section 30 lines above already said tokens carry no `iss` — the
document contradicted itself for a day. Also removed a claim that tests cover
"issuer mismatch including a trailing-slash-only difference"; that test was
deleted with the rule and no longer exists.
2. `ruview-auth/src/lib.rs:59` said "**No login flow.** ... this crate only
verifies" — 15 lines above `pub mod login;`. Now states the accurate thing:
the login flow is behind the non-default `login` feature, so a verifying
server never compiles it.
3. ADR-271's scope table still described the old prefix-denylist admin set.
Replaced with the fail-closed rule that actually ships, including why:
`/api/v1/adaptive/train` was reachable with `sensing:read`.
Also records a KNOWN INCOMPLETE that the ADRs previously implied was done: the
browser cannot obtain an OAuth token at all. `wifi-densepose login` writes
~/.ruview/credentials.json, which a browser cannot read; the UI reads
localStorage['ruview-api-token'], populated only by the manual-paste
QuickSettings panel. `grep -ril "oauth|cognitum|pkce" ui/` returns nothing.
So the WebSocket ticket mechanism ADR-272 introduces "for browsers" is today
only exercisable with the legacy static shared secret OAuth was meant to
replace. Server-side gating is correct and complete; the browser half of the
story these ADRs tell is not built. Recorded rather than left implied.
Co-Authored-By: Ruflo & AQE
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
The ADR said the login flow was "not in this crate". It is now, gated behind a
non-default `login` feature. The original line existed to keep the sensing
server lean; a feature gate achieves that without forcing the Tauri desktop app
to grow a second copy of a PKCE + rotating-refresh implementation — the kind of
duplication that drifts and then disagrees about something subtle.
Recording the change rather than letting the ADR quietly go stale, which is the
exact failure this session hit twice in other repos' ADRs.
Co-Authored-By: Ruflo & AQE
The previous commit referenced ADR-271 in five places without the ADR existing.
This writes it.
Records the decision and, more importantly, the direction — RuView verifies
tokens users present, it does not obtain tokens to call Cognitum. Every other
Cognitum OAuth integration in the org is the client side of a plane RuView does
not have; the sole relevant precedent is meta-llm's oauthBearer.ts.
Covers: why offline verification is a requirement rather than an optimisation
(Pi-class hosts lose WAN, and no introspection endpoint exists); why the
accept-rule is a port and not a design; why long-lived setup/workload
credentials are refused (no database to check revocation); why scope — not
`aud`, not `client_id` — is the capability boundary; and the alternatives,
including the `cog_`-minting approach that was tried org-wide and found broken
against production.
Co-Authored-By: Ruflo & AQE
Completes ADR-186 phases P5 (dashboard honesty / fallback guarantee) and P6
(tests + witness) on top of the P1–P4 reconnection, and flips the ADR to Accepted.
P5 — dashboard honesty:
- Backend (training_api): a runtime enablement gate (`RUVIEW_DISABLE_SERVER_TRAINING`)
makes the three start endpoints return a structured `{enabled:false, reason,
cli:"wifi-densepose train-room"}` HTTP 409 (never a silent success) when server
training is disabled; `/api/v1/train/status` now carries an `enabled` flag. A runtime
flag (not a Cargo feature) so `--no-default-features` builds keep training ON (resolves
§9.4). Handlers return `Response` to carry the 409.
- Frontend (TrainingPanel.js): reads `enabled` off the status payload and disables the
Start/Pretrain/LoRA buttons with a CLI tooltip when server training is off; a rejected
start tears down the optimistically-opened WS and refreshes. The enabled path was
already fully wired (opens the WS before POST, renders live epoch/loss/ETA + terminal
state) — verified.
P6 — tests & witness:
- New HTTP-level tests in a `#[cfg(test)] mod adr186_http_tests` built on a minimal
`AppStateInner::minimal()` test-state helper: a live-socket test that completes a
genuine 101 WebSocket handshake (tokio-tungstenite) and receives a real progress frame
after a POST start; a 426-not-404 route-wired check; a full POST→poll-status→`.rvf`-exists
round-trip; and the disabled-409 structured-response test. Added `tokio-tungstenite`
dev-dep (version already in the workspace lock).
- CHANGELOG updated; ADR §6 ledger + §7 acceptance criteria checked off with evidence;
Status → Accepted. README/CLAUDE have no training route table (no edit needed).
Fixed a test-only parallelism race found under `cargo test --workspace`: two
model-writing tests deleted `.rvf`s by directory-diff and could remove a file a third
test asserted existed. Each test now cleans only its own artifact (data/models is
gitignored).
Verified this session: `cargo test -p wifi-densepose-sensing-server -p wifi-densepose-train
--no-default-features` — sensing-server bin 217 passed / 0 failed, all train suites 0
failed. Full `--workspace` re-run in progress to reconfirm untouched crates.
Verified all 6 acceptance criteria against current file contents and checked them.
Set Status to Accepted; marked P1/P2/P3 DONE (1fb5397dd); left P4 as ACCEPTED-FUTURE (#645).
To make criterion 4 genuinely true (live ESP32 17-keypoint feature nowhere advertised
without the first-cut/below-target/runtime-stub caveat), added the caveat to the three
remaining live-pose advertisements in README.md: the recommended-hardware capability
table, the hero image caption, and the Live-ESP32-pipeline note.
Refs #509, #1125
* docs(adr): deep review of the RuView npm surface — ADR-263/264/265 optimization strategies
ADR-263 — @ruvnet/ruview@0.1.0 harness review (O1–O9):
- HIGH: claim-check CLI fails open on empty input (no --text/--file -> PASS exit 0)
- HIGH: MCP stdio server head-of-line blocking (spawnSync verify/calibrate up to 600s)
- MEASURED: optionalDependencies triple the cold npx install (4 pkgs/620kB/71 files
vs 1 pkg/172kB/22 files with --omit=optional) for a path that never imports them
- maxBuffer truncation, python -c port interpolation, version drift, duplicate skills,
guardrail METRIC_TERMS substring false positives ('map'/'F1' — found by dogfooding
claim-check on these very ADRs), zero CI
ADR-264 — @ruvnet/rvagent@0.1.0 + @ruv/ruview-cli review (O1–O9), verified against
the published registry tarball:
- HIGH: exports.require -> dist/index.cjs which is never built nor published
- MEASURED: 44 dead source-map files = 62,698B of the 188kB unpacked payload
- stdio-only server described as dual-transport; mixed dot/underscore tool names;
double Zod validation + hand-duplicated advertised schemas; 2-fd leak per training
job; unbounded body in the unwired HTTP scaffold; dead detectCogBinary candidates;
ruview bin-name collision
ADR-265 — cross-cutting npm distribution strategy: npm-packages.yml CI matrix
(test + pack-content/size gate + tarball-install smoke test), publish-from-CI-only
with npm provenance, version single-sourcing from package.json, bin/namespace
ownership (ruview bin belongs to @ruvnet/ruview), claim-check on package READMEs.
Docs only — no runtime code changed. Index/CHANGELOG/CLAUDE.md/README counts updated.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz
* fix(npm): implement ADR-263/264/265 — harness fail-closed + async MCP, rvagent packaging/transport/naming, npm CI+provenance gate
ADR-263 (@ruvnet/ruview 0.2.0), O1-O9:
- claim-check fails closed on empty input (CLI exit 2, empty_text tool error)
- MCP stdio server dispatches tools/call asynchronously (promise-based spawn);
ping answers while a 3s fake verify runs — pinned by new e2e test
- optionalDependencies dropped: cold npx installs exactly 1 package
(MEASURED: was 4 pkgs/620kB/71 files via npm i in a clean prefix)
- bounded rolling output tails replace spawnSync 1MiB maxBuffer
- node_monitor port passed via sys.argv, never spliced into python -c source
- serverInfo.version read from package.json; resources/prompts stubs
- skills single-sourced: prepack sync script generates .claude/skills/ copies
- which() = memoized dep-free PATH scan
- tools underscore-canonical (ruview_claim_check, ...) + dotted aliases
- guardrail precision: word-boundary map/f1/auc/iou, code-span + F1/O2 label
scrubbing, quantitative-claims-only; packaging reproducer hints
- 30/30 tests (was 17), incl. concurrency e2e + fail-open regression pins
ADR-264 (@ruvnet/rvagent 0.2.0), O1-O9:
- exports fixed: types-first, phantom dist/index.cjs require target removed
- tarball map-free: 127,704B unpacked / 46 files / 0 maps (MEASURED,
npm pack --dry-run; was 188kB incl. 44 maps referencing unshipped src)
- Streamable HTTP actually wired behind RVAGENT_HTTP_PORT: one transport +
one MCP server per session (mcp-session-id routing), 1MiB body cap (413),
port-aware localhost origin gate; dual-transport description now true
- tools renamed underscore-canonical with dotted router-only aliases
- single Zod validation gate; advertised inputSchema generated from the same
Zod source (zod-to-json-schema)
- train_count: parent log fds closed (was leaking 2/job); job records
persisted to <jobsDir>/<id>.json (job_status survives restarts); bounded
log-tail reads
- detectCogBinary probes its candidates instead of dead-coding them
- version from package.json; @types/express dropped; @types/jest -> 29
- README rewritten to match reality (no phantom subcommands/policy layer)
- 99/99 jest tests (incl. new session/body-cap suite + previously-broken
manifest suite); stdio handshake + HTTP session flow smoke-tested live
ADR-265 D1-D4:
- .github/workflows/npm-packages.yml: 3-package x Node 20/22 gate — tests,
version-literal grep (D3), pack-content/size gate, tarball-install smoke
test (catches the ADR-264 F1 class), README claim-check (D4)
- .github/workflows/ruview-npm-release.yml: publish from CI only with
npm publish --provenance
- @ruv/ruview-cli bin renamed ruview-cli (ruview bin belongs to
@ruvnet/ruview); version single-sourced
- ci.yml NODE_VERSION 18 -> 20
ADR statuses updated to Accepted/implemented; harness manifest re-pinned;
ADR-263/264/265 + both package READMEs pass claim-check.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz
* perf(rvagent): lazy-load HTTP transport + memoize generated tool schemas
stdio time-to-first-response ~242ms -> ~189ms (-22%; MEASURED, median of
repeated initialize round-trips against dist/index.js in this container).
- ./http-transport.js now imported lazily inside the RVAGENT_HTTP_PORT
branch: it chain-loads the MCP SDK streamableHttp module (~48ms MEASURED
via per-module import() timing) which the default stdio path never uses
- toolInputJsonSchema memoized per tool: schemas are static for the process
lifetime; under the session-per-server HTTP model every session calls
tools/list, so stop re-walking the Zod tree each time
No behavior change: 99/99 jest tests; HTTP session flow re-smoke-tested
through the lazy import path (initialize -> 200 + mcp-session-id).
Profiled @ruvnet/ruview too and left it alone: 50ms CLI startup vs ~29ms
bare 'node -e ""' floor on the same box (MEASURED) — already near the
interpreter floor with zero dependencies.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz
* ci(ruview-cli): pass jest --passWithNoTests so the private no-test package doesn't fail the npm-packages matrix
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(npm): address 10 verified review findings in harness + rvagent before 0.2.0 publish
harness/ruview (@ruvnet/ruview):
- guardrails: digit gate now sees numbers inside code spans; F1-style
metric tokens followed by ':' or a nearby number are no longer scrubbed
(fail-open regressions in the honesty gate)
- mcp-server: tools/call requests serialize through a FIFO promise chain
(hardware/mutating tools never overlap) while ping/tools/list stay
immediate; stdin close drains in-flight responses before exit
- tools: which() no longer memoizes negative lookups
tools/ruview-mcp (@ruvnet/rvagent):
- index: realpath invoked-directly guard — library import no longer
connects a stdio transport to the consumer's process
- http-transport: explicit allowedOrigins is exact-match only (localhost
any-port convenience applies only with no configured allowlist);
session map gains maxSessions=64 + 5min idle TTL sweep
- train-count: job records persist the child pid and reconcile stale
'running' status after a server restart (exit-code marker or dead pid)
- config: cog binary candidates ordered by process.arch
.github/workflows/ruview-npm-release.yml: port the full ADR-265 D1 gate
(version-literal check, unpacked-size budget, tarball-install smoke test)
from npm-packages.yml so the publish path enforces what the header claims.
Tests: harness 30→36, rvagent 99→112, all passing.
Co-Authored-By: claude-flow <ruv@ruv.net>
---------
Co-authored-by: Claude <noreply@anthropic.com>
The 40 Hz gamma flicker is now Kconfig-gated (default y, unchanged
behaviour). Set CONFIG_LED_GAMMA_VIZ=n for a dark, lower-power boot (the
LED is simply cleared) — important for photosensitive deployments, no
source edit needed. The colormap saturation point is now operator-tunable
via CONFIG_LED_MOTION_FULLSCALE_MILLI (default 250 = 0.25).
Build + flash confirmed on ESP32-S3 N16R8 (COM8): default y still arms the
gamma timer, CSI flows. ADR-183 updated (gate moved from follow-up to done).
* chore(deps): bump ruv-neural submodule — ColorMap no_std for ESP32
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.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(firmware): onboard LED as 40 Hz gamma stimulus, colour from live CSI motion (ADR-183)
The S3 onboard WS2812 (GPIO 48, #962) now runs a GENUS-style 40 Hz gamma
square wave (12.5 ms on/off, 50% duty). The ON-phase colour is live CSI
motion (edge motion_energy) mapped through a 60-step viridis LUT generated
from ruv-neural-viz::ColorMap::viridis() — still=purple, moving=yellow.
Uses the now-no_std ColorMap (ruvnet/ruv-neural#3 / #1126). Hardware-
confirmed on ESP32-S3 N16R8 (COM8): boot log shows the timer armed, CSI
keeps flowing (27-38 pps). Honesty + photosensitivity notes + a Kconfig-gate
follow-up are in ADR-183.
Co-Authored-By: claude-flow <ruv@ruv.net>
A host-portable RuView agent harness minted via MetaHarness and hardened
per ADR-182. Published as @ruvnet/ruview@0.1.0 (bare `ruview` blocked by
npm's typosquat filter → scoped fallback).
What it does:
- 6 fail-closed `ruview.*` tools (onboard, claim_check, verify,
node_monitor, calibrate, node_flash) exposed as CLI verbs + a
dependency-free MCP stdio server.
- The "prove everything" rule made executable: `ruview.claim_check`
flags untagged accuracy claims and the retracted "100%" framing.
- 5 host-neutral skills (onboard/provision-node/calibrate-room/
train-pose/verify) + bundled .claude/ config + provenance manifest.
Validated: 17/17 unit tests, live MCP handshake, `ruview.verify` ran the
real verify.py to VERDICT: PASS, clean `npx @ruvnet/ruview` from registry.
Packs to 16.7 kB / 21 files; kernel+host are optionalDependencies so the
operator tools install lightweight.
README: documented as the portable, multi-host companion to the in-repo
plugins/ruview/ Claude Code plugin (not a replacement).
* 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>
* 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>
* 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 37764be55 (NVSIM-DT-01 config-induced
overflow panic / WASM-abort DoS; NVSIM-NAN-01 non-finite scene param →
silent fake zero-field reading with saturation flag clear) + 3 pins, and the
clean-with-evidence determinism/deser/div-by-zero verdict. Cross-machine
witness cc8de9b0…93b4 reproduces unchanged.
Co-Authored-By: claude-flow <ruv@ruv.net>
* 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 f671000d7 (collision
avoidance, battery RTH, geofence, anti-jamming %0 panic — all NaN/Inf
defeating a safety comparison at the swarm-comm trust boundary) + 6 pins,
5 clean-with-evidence dimensions, and the 2 genuine issues deferred to a
focused follow-up (Raft AppendEntries log-matching; MAVLink signer
constant-time + replay window).
Co-Authored-By: claude-flow <ruv@ruv.net>
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).
* ci(bench): wire v2 criterion benches into CI as a compile-verify regression gate
Sub-deliverable 8.3 of the benchmark/optimization milestone (needs ADR slot 174).
The v2/ workspace ships 26 criterion benches across 18 crates, but benches are
not part of `cargo test`, so nothing in CI compiled them and they silently rot
when a public API they call changes.
Add `.github/workflows/bench-regression.yml`:
- bench-compile (HARD GATE): `cargo bench --workspace --no-default-features
--no-run` compiles + links every default-feature bench (no measurement) plus
the cir-gated cir_bench — a real, deterministic regression guard against
bench bit-rot.
- bench-fast-run (INFORMATIONAL, continue-on-error, never gates): runs a
curated pure-CPU subset (nvsim, ruvector sketch/fusion) in criterion
quick-mode and uploads logs as an artifact.
No timing-regression gate, by design: wall-clock on shared GitHub runners varies
2-3x run-to-run, so a hard threshold or cross-runner `criterion --baseline`
compare would manufacture false failures. The honest scope is compile-verify +
informational-run; the workflow header documents the self-hosted-runner
condition under which true timing-gating becomes honest. The crv-gated crv_bench
is excluded because its crates.io dep ruvector-crv 0.1.1 fails to build upstream.
Running the gate immediately caught one already-bit-rotted bench:
wifi-densepose-mat/detection_bench failed to compile (E0063: missing field
last_rssi in SensorPosition). Fixed (last_rssi: None) and re-verified.
Validation (MEASURED): mat detection_bench + cir_bench + nvsim + ruvector +
vitals + swarm benches compile under --no-default-features; fast subset runs;
`cargo test -p wifi-densepose-mat --no-default-features` 174 passed / 0 failed;
Python proof PASS, hash f8e76f21...46f7a unchanged.
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr): ADR-174 — CI bench-regression compile-verify gate
Records sub-deliverable 8.3 (bench-regression.yml, committed c4c59e085):
a hard compile-verify gate over all 26 v2 criterion benches (caught + fixed
one real bit-rotted bench, mat/detection_bench E0063) + an informational
fast-run. Documents the honest scope — no timing-regression gate, since
shared-runner wall-clock varies 2-3x; states the self-hosted-runner condition
under which timing gating becomes honest.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(train): metric-locked PCK/MPJPE accuracy harness — resolve PCK-definition ambiguity
The SOTA brief (docs/research/sota-nn-train-benchmark-brief.md §1/§3.1/§4)
identifies metric ambiguity as the single biggest threat to any beyond-SOTA
claim: three PCK@20 numbers (96.09% WiFlow-STD image-normalized, 81.63%
AetherArena torso-PCK, 61.1% GraphPose-Fi standard PCK) cannot be lined up
because each silently uses a different normalization. The project was retracted
twice over this (a withdrawn 92.9% used absolute pixels, not torso).
New src/accuracy.rs makes the normalizer explicit, selectable, and carried with
every reported number:
- PckNormalization enum: TorsoDiameter (standard MM-Fi/GraphPose-Fi hip↔hip),
BoundingBoxDiagonal (looser WiFlow-STD image-normalized), AbsolutePixels(t)
(retracted convention, reproducible + clearly non-comparable).
- pck_at(pred, gt, vis, k, normalization) — one canonical PCK reusing the
metrics_core geometric primitives (no duplicate kernel).
- mpjpe(pred, gt, vis) — 2D/3D, mm.
- PoseAccuracy { pck_at: BTreeMap<u8,f32>, mpjpe, normalization, n_keypoints,
n_frames } via accuracy_report(frames, ks, normalization) — an unlabeled PCK
number is structurally impossible.
17 hand-computed deterministic tests (no GPU, no datasets) prove the harness
arithmetic, including the key proof that identical predictions score
0.50 / 1.00 / 0.75 under the three normalizations, plus graceful degenerate
handling (zero torso, empty frames, NaN coords — no panic, never false-perfect).
This is measurement infrastructure, NOT an accuracy claim. Public API worth an
ADR — needs ADR slot 173 (parent to write).
wifi-densepose-train lib 191→206, test_metrics 12→14, 0 failed; full workspace
green (exit 0); Python deterministic proof unchanged
(f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a).
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr): ADR-173 — metric-locked PCK/MPJPE accuracy harness
Documents the accuracy harness (committed 3a8b2ed13) that resolves the
PCK-definition ambiguity flagged as the #1 beyond-SOTA risk in the SOTA brief
(#1090): three historical numbers (96/81.6/61) used three unstated
normalizations. The harness makes normalization explicit + selectable
(PckNormalization enum) and every reported number carries its definition.
Key proof: identical predictions → 0.50/1.00/0.75 under torso/bbox/abs.
Co-Authored-By: claude-flow <ruv@ruv.net>
* test(core,cli): pin DoS-resistance of CSI deserialisers (ADR-127 security review)
Beyond-SOTA security review of wifi-densepose-core + wifi-densepose-cli.
Load-bearing-question verdict: the NaN-state-poisoning bug class does NOT
originate in core — core exposes no stateful accumulator (no Welford,
von-Mises, IIR, voxel grid, running mean); each downstream crate rolls its
own, so each fix is correctly local. Both crates confirmed clean on every
reviewed dimension (panic-on-adversarial-input, NaN handling, unbounded
memory, path traversal, secrets) — no production code changed.
Adds 4 regression pins locking in two existing-but-untested DoS guards:
- core: from_canonical_bytes shape guard (Vec::with_capacity bound) — proven
to fail with `capacity overflow` when the saturating-mul guard is removed.
- core: canonical decoder never panics on arbitrary/truncated bytes.
- cli: parse_csi_packet rejects an oversized n_antennas*n_subcarriers claim
before Array2 allocation (33 MB claim in a 2 KB datagram -> None).
- cli: parse_csi_packet never panics on arbitrary UDP bytes.
core: 35 -> 37 lib tests; cli: 24 -> 26 tests; 0 failed. Python proof
unchanged (f8e76f21…46f7a — off the signal path).
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr): ADR-172 — wifi-densepose-cli + core CSI-deserialiser security review
Records the clean-with-evidence verdict + 4 DoS-resistance regression pins
(test-only, committed in a1051607d). Documents the load-bearing finding:
the NaN-state-poisoning bug class does NOT originate in a shared core
primitive (core exposes no stateful accumulator — MEASURED via grep), so
the 3 prior downstream-local fixes are complete. Gives the wifi-densepose-cli
review its own ADR slot (core portion cross-refs ADR-127 §9).
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(homecore-migrate): redact secret value from malformed secrets.yaml error (secret-leak)
`read_secrets` wrapped serde_yaml's parse error into `MigrateError::YamlParse {
source }`. serde_yaml's message for a typed-tag coercion failure embeds the
offending scalar verbatim, e.g. `invalid value: string "<the-secret-value>"`.
That error propagates out of `read_secrets`, is `?`-returned by the
`InspectSecrets` CLI path in main.rs, and printed to stderr by anyhow — leaking
a secret value despite the CLI's deliberate `<redacted>` design.
Fix: secrets.yaml parse failures now map to a new redacting variant
`MigrateError::SecretsParse { path, line, column }` that carries only the file
path and a coarse location (from `serde_yaml::Error::location()`), never the
scalar content. Other (non-secret) YAML files keep `YamlParse`.
Pinned by `secrets::tests::malformed_secrets_error_never_contains_secret_value`
(asserts the rendered error AND its full #[source] chain never contain the
secret value; fails on the old `YamlParse` path) plus
`malformed_secrets_error_reports_location` (still fail-closed + locatable).
ADR-165 secret-handling rule: a secret value must never appear in output.
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(homecore-migrate): record secret-leak fix in ADR-165 + CHANGELOG
Note the secrets.yaml error-redaction fix and the review's clean dimensions
(read-only source / no traversal / no panic / fail-closed versioning / no
injection) in ADR-165 §2.4, bump the test-evidence count 19→21 in §2.6, and add
an [Unreleased] Security entry to CHANGELOG.
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(homecore): atomic state set — close TOCTOU lost/reordered state_changed events
StateMachine::set did get() (release shard lock) → compute next + no-op
decision → insert() (re-acquire lock) → send(). The read-modify-write was
not atomic w.r.t. a concurrent writer on the same entity: a writer that
read a stale `old` could mis-classify a real transition as a no-op and drop
its state_changed event (a missed automation trigger) or fire an event whose
new_state duplicated the previously delivered one (a spurious trigger for any
automation keyed on old_state != new_state). ADR-127 §2.1 promises "writer
atomically replaces the map entry"; the implementation did not.
Fix: hold the DashMap shard write-lock across the whole read→decide→insert→
fire sequence via entry()/insert_entry(). tx.send is non-blocking, non-async,
and never re-enters the map, so firing under the shard lock cannot deadlock
and keeps global event order in lock-step with global commit order.
Pinned by concurrent_set_fires_no_duplicate_adjacent_events: 4 writers
toggling one entity A/B; asserts no two consecutive fired events carry the
same new_state (impossible under correct serialisation). Fails reliably on
the old code (~365-476 duplicate-adjacent events on the first trial), passes
on the fix across repeated runs.
Co-Authored-By: claude-flow <ruv@ruv.net>
* harden(homecore): bound entity_id length — close memory-DoS at the REST boundary
homecore-api/src/rest.rs parses untrusted path segments straight through
EntityId::parse (get/delete/set_state). With no length cap, an otherwise-valid
id like "a." + many MB of [a-z0-9_] was accepted; a POST /api/states/<giant>
would persist it into the DashMap state store, permanently growing memory
(amplification across distinct ids).
Fix: reject ids longer than MAX_ENTITY_ID_LEN (255, HA-compatible) up front in
parse(), before any per-char scan, with a new EntityIdError::TooLong. Fails
closed at the boundary type so every caller (REST, registry deserialize,
automation) is protected.
Pinned by entity_id_length_boundary: exactly-MAX accepted, MAX+1 rejected,
4 MiB id rejected as TooLong. Fails on old code (oversized parses Ok).
Co-Authored-By: claude-flow <ruv@ruv.net>
* harden(homecore): isolate panicking service handlers (catch_unwind)
ServiceRegistry::call already ran handlers outside the registry lock (the
Arc<dyn ServiceHandler> is cloned out of the read guard first), so a panic
could never poison the RwLock or block other callers — good. But a panicking
handler unwound through call() into the caller's task; the task driving the
engine (e.g. an axum request handler invoking a service) could be aborted by
one buggy integration.
Fix: wrap the handler future in AssertUnwindSafe + FutureExt::catch_unwind and
convert a panic into ServiceError::HandlerPanicked. Mirrors HA isolating
service-handler exceptions. The registry stays fully usable afterwards.
Pinned by panicking_handler_is_isolated_and_registry_survives: the panicking
call returns HandlerPanicked (not an unwind), a sibling healthy service still
returns its value, and the bad service remains registered. Fails on old code
(the await point panics instead of returning Err).
Co-Authored-By: claude-flow <ruv@ruv.net>
* test(homecore): pin event-bus lag safety (bounded broadcast, no DoS)
Documents-with-evidence that the core EventBus does NOT have the homecore-api
WS broadcast-lag failure: with EVENT_CHANNEL_CAPACITY=4096, firing 3x capacity
while a subscriber never drains keeps fire_* non-blocking (publisher never
waits on slow receivers), gives the slow receiver a recoverable Lagged(n)
(drop-oldest + re-sync) rather than a closed channel, and leaves the bus live
for a fresh fast subscriber. No code change — pins the clean dimension.
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(homecore): record ADR-127 §9 security+concurrency review + CHANGELOG
Documents the three pinned fixes (HC-RACE-01 state-set TOCTOU, HC-EID-LEN-01
entity_id memory-DoS, HC-SVC-PANIC-01 service-handler isolation) and the
clean dimensions (bounded event-bus lag handling, lock discipline / no
lock-across-await, no panic-on-input) with their evidence.
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(homecore-assist): bound untrusted utterance length, fail closed (ADR-133 security)
The intent recognizers accept utterances from untrusted callers (voice
transcripts, the WebSocket `assist` command). Neither the regex nor the
semantic path bounded utterance length, so a pathological multi-megabyte
utterance forced an unbounded `to_lowercase()` clone plus a per-registered-
pattern scan (and, in the semantic path, full tokenisation + feature-hash
embedding) — an allocation/CPU amplification on attacker-controlled input.
The `regex` crate is linear-time (no catastrophic backtracking), so this was
a throughput/memory DoS rather than a hang, but it was still unbounded.
Fix: introduce MAX_UTTERANCE_BYTES (4 KiB — far above any real spoken
command) and check it at both recognizer boundaries BEFORE any allocation or
scan. An over-length utterance fails closed: Ok(None) (no intent, no action),
identical to an unrecognised phrase. No legitimate command is affected.
Pinned by fails-on-old tests:
- recognizer::over_length_utterance_fails_closed — an over-length utterance
that contains a valid command resolves to None (would have matched before)
- semantic_recognizer::over_length_utterance_fails_closed_semantic
Co-Authored-By: claude-flow <ruv@ruv.net>
* test(homecore-assist): pin clean security dimensions with evidence (ADR-133)
Adds regression tests documenting the dimensions reviewed and found clean,
so the properties cannot silently regress:
- runner: no subprocess surface exists. RufloRunnerOpts.{script_path,env}
are inert and never executed; even a hostile script_path/env spawns
nothing. And the entity_id capture class [a-z0-9_ .] strips every shell
metacharacter, so a resolved slot can never carry ; | & $ ` / etc into a
(future) argv — sanitisation by construction.
(shell_metachars_never_survive_into_a_resolved_slot,
runner_opts_are_inert_no_process_spawned)
- recognizer: the regex crate is a linear-time finite automaton; a classic
catastrophic-backtracking shape (a+)+$ on adversarial input completes in
bounded time — no ReDoS.
(pathological_backtracking_pattern_completes_in_bounded_time)
- embedding: embeddings are structurally finite (FNV feature-hash + guarded
L2 normalise, no external float input, no unguarded division), so a crafted
utterance cannot inject NaN/Inf to poison cosine k-NN; cosine against the
zero vector is a finite 0.0, never NaN.
(embeddings_are_structurally_finite, cosine_with_zero_vector_is_finite_not_nan,
empty_utterance_against_empty_index_no_panic_no_match)
- pipeline: injection-shaped utterances never deliver a metacharacter into a
service call; the worst case resolves to a clean entity token, and an
unrecognised utterance fails closed to not_understood (no action).
(pipeline_injection_shaped_utterance_carries_no_metachars_to_service)
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(homecore-assist): record ADR-133 security review (HC-ASSIST-01 + clean dims)
CHANGELOG [Unreleased] Security entry + ADR-133 section 6 review notes for the
homecore-assist voice/intent pipeline review.
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(homecore-recorder): bound history query + add transactional purge (memory-DoS + disk-DoS)
Security review of the HA-compat state recorder (ADR-132) found two real
bounding bugs; SQL-injection and NaN-index dimensions confirmed clean.
(1) Memory-DoS: get_state_history carried no LIMIT — a wide [since,until]
window over a high-frequency entity loaded an unbounded row set into a
single in-memory Vec. Added LIMIT MAX_HISTORY_ROWS (1,000,000); the
sibling search paths were already k-bounded.
(2) Disk-DoS / documented-but-missing purge: README advertised
Recorder::purge(older_than) but no retention path existed -> unbounded
disk growth. Added a transactional purge with an EXCLUSIVE cutoff
(idempotent, no off-by-one) that deletes old states+events and
garbage-collects orphaned state_attributes blobs (dedup-shared blobs
are kept until their last referencing state is gone). All three deletes
run in one transaction so a mid-purge failure rolls back cleanly.
Pinning tests (homecore-recorder 19->25 no-default / 25->31 ruvector, 0 failed):
- malicious_entity_id_is_stored_literally_not_executed (SQL injection)
- like_metacharacters_in_query_are_literal_not_wildcards (LIKE escape)
- history_query_carries_a_limit_clause (memory-DoS bound)
- purge_keeps_boundary_row_and_drops_older (exclusive-cutoff, true pin)
- purge_gcs_orphaned_attributes_but_keeps_shared (dedup-safe GC)
- purge_also_removes_old_events
No behaviour change beyond the two fixes. Python deterministic proof
unchanged (recorder is off the signal proof path).
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(homecore-recorder): record ADR-132 security review findings
Add a "3a. Security review" section to ADR-132 and a CHANGELOG [Unreleased]
Security entry covering the homecore-recorder review: SQL-injection and
NaN-index dimensions confirmed clean with evidence (every query bound; LIKE
pattern bound+escaped; SHA-256->i32->f32 embeddings always finite, empty
index/k=0 probed no-panic), plus the two fixes (unbounded history LIMIT,
transactional exclusive-cutoff purge with orphan-attribute GC).
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(homecore-automation): bound template render to stop unbounded-expansion DoS (HC-SEC-01)
A `template:` condition / value_template comes straight from user
automation config and was rendered with MiniJinja's default (no
instruction budget, no output cap). A single condition such as
`{% for i in range(5000) %}{% for j in range(5000) %}xxxx{% endfor %}{% endfor %}`
rendered a 100 MB string over ~11 s on one render call (proven
empirically) — a CPU/memory denial of service, the bfld-class
"unbounded expansion".
Fix:
- Enable MiniJinja's `fuel` feature and set a per-render instruction
budget (`set_fuel(Some(1_000_000))`). A nested loop burns one unit
per iteration, so the budget caps total work regardless of nesting;
the attack now fails fast (~90 ms) with "engine ran out of fuel".
- Reject template sources over 64 KiB before compilation (defense in
depth so a pathological literal can neither compile nor emit verbatim).
Legitimate HA templates (a few dozen instructions) are unaffected.
Tests (fail on old — unbounded render / no rejection):
- nested_loop_template_is_bounded_not_unbounded_dos
- single_huge_repeat_template_is_bounded
- oversized_template_source_is_rejected
- legitimate_template_still_renders_within_fuel (no regression)
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(homecore-automation): stop crafted delay/timeout from panicking the run task (HC-SEC-02)
`Action::Delay { seconds }` and `Action::WaitForTrigger { timeout_seconds }`
fed the user-supplied float straight into `Duration::from_secs_f64`, which
PANICS on negative, NaN, infinite, or overflowing inputs. All of those are
reachable from a crafted (or simply typo'd) automation YAML —
`delay: {seconds: -1}`, `.nan`, `.inf`, `1e308` — so one hostile config
aborts the spawned automation task with a panic
("cannot convert float seconds to Duration: value is negative", proven
empirically).
Fix: a `safe_duration_from_secs` guard that saturates instead of panicking,
matching Home Assistant's lenient "non-positive delay = no delay":
- NaN / ±inf / negative -> Duration::ZERO
- absurdly large (would overflow) -> clamped to ~100 years (MAX_DELAY_SECS)
Tests (fail on old — panic = failure):
- delay_negative_seconds_does_not_panic
- delay_nan_seconds_does_not_panic
- delay_infinite_seconds_does_not_panic
- wait_for_trigger_negative_timeout_does_not_panic
- safe_duration_saturates_hostile_values (incl. overflow clamp)
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(homecore-automation): record HC-SEC-01/02 security review (CHANGELOG + ADR-129 §8a)
Document the two DoS findings (template unbounded-expansion HC-SEC-01,
delay panic-on-config HC-SEC-02) and the dimensions probed clean
(condition fail-closed, bounded run-modes, sandboxed read-only templates).
Co-Authored-By: claude-flow <ruv@ruv.net>