Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bb9ee0e
docs(security): correct three statements #1149's own build left false…
Sep 6, 2026
74511b6
fix(apiclient): handle the dual-control 202 hold on all three gated c…
Sep 6, 2026
1351a5c
fix(api): invert the attachment MIME classifier to an allow-list (BAC…
Sep 6, 2026
ee08879
refactor(parsing): delete the unenforced conformance profile paramete…
Sep 6, 2026
f5bbbca
fix(pipeline): report what a config reload actually did (BACKLOG #1111)
Sep 6, 2026
a13332b
fix(transports): refuse a missing or blank outbound host on MLLP, TCP…
Sep 6, 2026
c5d1e9a
fix(cli): reach the unified-store guard from serve --shard (BACKLOG #…
Sep 6, 2026
84b3d50
fix(ide): stop the live-status poll carrying a bearer on a timer (BAC…
Sep 6, 2026
4b4200f
feat(transports): shipped-on per-tick ceilings for the three poll sou…
Sep 6, 2026
02b33cb
feat(auth): rotate the session token at all five elevation sites (BAC…
Sep 6, 2026
67c17f5
fix(tests): two rotation call sites my #1146 commit left behind (BACK…
Sep 6, 2026
30b9ce3
refactor: apply the /simplify pass over the packet D branch (BACKLOG …
Sep 6, 2026
a37c744
docs(backlog): record packet D's landings, and correct two rows that …
Sep 6, 2026
f9651bb
refactor(auth): derive Elevation.ok instead of storing it (BACKLOG #1…
Sep 6, 2026
84e348b
fix(webconsole): the Elevation call site my own refactor missed (BACK…
Sep 6, 2026
2063598
chore(seam): bump ENGINE_UI_SEAM for the Elevation return types (BACK…
Sep 6, 2026
d7d5ca1
Merge origin/main into packet D, and invert the rotation tests main w…
Sep 6, 2026
fcc059b
Merge main into claude/asvs-validation-brief-8b88c3
Sep 8, 2026
2a4f6cf
Merge origin/main: regenerate the engine-console seam from the merged…
Sep 8, 2026
569349e
fix(tests): classify two new response surfaces the guards flagged
Sep 9, 2026
0ad2837
Merge origin/main: keep the branch poll ceiling, port main's stop che…
Sep 9, 2026
b10d1fe
Merge remote-tracking branch 'origin/main' into lander/tmp-948-seam
Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10059,6 +10059,43 @@ A recycled name inside one directory is **not** cross-IdP spoofing, so a 6.8.1 r

**NOT built: the rotation wiring, and the reason is that no site is free.** Every elevation happens on an already-live session whose token the client is holding, so rotating anywhere requires the new token to reach that client in the same response -- which is the breaking token-delivery contract, the apiclient token-adoption work and the console cookie re-set, all of which this item already lists as separate proposed work. There is no cheapest-site increment that is honest on its own: the one arm that needs no delivery is `POST /me/password`, and it needs no rotation either because `change_password` revokes everything. So the residual is unchanged from the paragraph above, minus the citation repair and minus the behavioural suite. **The cell should not move on this commit** -- a suite that pins the gap is not the gap closed.

**THE PARAGRAPH ABOVE IS SUPERSEDED AND ITS REASONING WAS SOUND WHEN WRITTEN.** It concluded that no site was free because rotating anywhere requires the new token to reach the client in the same response -- the token-delivery contract, the apiclient adoption and the console cookie re-set. That is correct, and it is why the build below did all of them together rather than looking for a cheapest site. The suite it left in current-state polarity is what the wiring inverted, exactly as its own docstring said it should.

**BUILT 2026-09-06: rotation wired at ALL FIVE elevation sites. The item stays OPEN** -- the
closing act is a scorecard re-score, which no builder performs.

**Not the cheap subset, deliberately.** This row warns that the 2026-07-25 owner ruling names two JSON
routes and that building precisely those would look like building to the owner's own words while
leaving `confirm_mfa_enrollment`, `finish_webauthn_registration` and `finish_webauthn_assertion`
un-rotated -- and that for `POST /ui/mfa` the passkey assertion is the ONLY leg. All five are wired.

**The ordering invariant lives in ONE place.** A private `_elevated()` is the sole caller of
`_rotate_session_token`, so the rule that every rowcount-blind stamp must land BEFORE the rotation is
stated once rather than in nine route handlers. Both site-specific traps are handled: `reauth` decides
`_factor_binding_is_blocked` before the rotation and mints its grant after, against the new hash;
`verify_mfa`'s three-write group lands first. A rotation on a vanished session reports `session_lost`,
which the routes map to 401 rather than the 403 a wrong proof gets -- a correct password must not be
reported as incorrect because the session died mid-ceremony.

**The pass rests on BEHAVIOUR, not on the marker**, exactly as this row requires: the cell's absence
claim keys on the primitive's own call pattern, so wiring it for effect would flip the marker and
change nothing. `tests/test_session_rotation_wiring.py` asserts a pre-elevation token stops
authenticating at the moment of elevation, one case per site, with a mutation-checked negative control
(making `reauth` rotate unconditionally reds it and nothing else).

**The WebSocket question this row asks is answered and recorded in code.** A rotation would drop an
open `/ws/stats` socket at the next revalidation tick, which is fail-closed and correct; `app.js`
resumes the HTTP poll carrying the new cookie, so completing MFA would cost the live push for the rest
of that page's life. A liveness regression, not correctness or data loss -- which is why the bounded
reconnect is deferred rather than built.

**Also repaired, as this row asks:** the test file's citation of a backlog number that cannot resolve
from a public checkout now names the subject instead.

**Still unbuilt, named by subject:** the IDE sign-in supersession; login supersession on the three
console cookie-minting legs and written rationales for both bearer login legs; the console
self-session revoke identifier; and the bounded console WebSocket reconnect.

## 1147. research an honest pass for ASVS 7.4.3 -- offering session termination as part of the MFA-change ceremony rather than beside it

> 🔢 **Re-scored 2026-08-20 -> P2.** Value **5/10** · Difficulty **4/10** · _fill-in_. disable_mfa still offers and revokes nothing, and the post-disable redirect still lands on a page whose only relation to session termination is a link, so the option remains adjacent to the ceremony rather than part of it. The capability ships and is one click away, which caps value; difficulty 4 covers research plus a uniform ceremony across five factor-change paths without cutting the caller's own session mid-flow. _(was 5/10 · 4/10.)_
Expand Down
89 changes: 74 additions & 15 deletions docs/CONNECTIONS.md

Large diffs are not rendered by default.

20 changes: 12 additions & 8 deletions docs/PHI.md
Original file line number Diff line number Diff line change
Expand Up @@ -825,14 +825,18 @@ control unchanged (`messages:view_raw`/`view_summary` RBAC, field-level redactio
- **Attachments are neutralized at serve, never rewritten.** A detached document (ADR 0105) is a
verbatim clinical payload carrying its own attacker-influenced `OBX-5.2` MIME label, and the
preserve-the-original invariant forbids editing the stored bytes — so the browser-safety control runs
at *serve* time, not on the stored document: a browser-active label (`html`/`xml`/`script`/`svg`,
case-folded) is downgraded to `application/octet-stream`, which also strips a `.svg`/`.html` download
name, and the response carries `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`
and `Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors 'none'` on both the JSON
route and the `/ui` delegate. No served representation can execute in the application origin, and
none can be framed -- `frame-ancestors` is named in that policy rather than left to the API's header
floor because it takes no fallback from `default-src` (ASVS 3.4.6). Trade-off: `svg`/`html`
attachments no longer preview in the browser; the bytes are unchanged and still downloadable.
at *serve* time, not on the stored document. The served `Content-Type` comes from an **allow-list** of
inert types matched case-folded and exactly (`api/app.py`); a browser-active label such as `text/html`,
`image/svg+xml` or `application/hta` is simply not on it, so it is declared `application/octet-stream`,
and the same table gives the download name a `.bin` extension instead of `.svg`/`.html`/`.hta`. The
response carries `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff` and
`Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors 'none'` on both the
JSON route and the `/ui` delegate.
No served representation can execute in the application origin, and
none can be framed -- `frame-ancestors` is named in that policy rather than left to the API's
header floor because it takes no fallback from `default-src` (ASVS 3.4.6). Trade-off: `svg`/`html` attachments no
longer preview in the browser; the bytes are unchanged and still downloadable, since the allow-list
governs the declared type and never whether the file is served.
- **XSS-safe rendering.** All HL7/message content is escaped by an autoescape-by-default renderer and a
strict CSP (`script-src 'self'`, no `unsafe-*`); attacker-influenced HL7 cannot execute in the DOM.
- **Residual (documented, not a claimed control):** a shared clinical workstation, browser devtools, or a
Expand Down
41 changes: 32 additions & 9 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1470,12 +1470,26 @@ session alive. `[auth].max_sessions_per_user` caps concurrent sessions (default
the cap revokes the user's oldest — ASVS 7.1.2; `0` = unlimited). Clients send the token as
`Authorization: Bearer <token>` (the WebSocket prefers the header; the legacy `?token=` query param is
deprecated because it leaks into proxy/access logs). The token is a **PHI-scoped** credential (the
user's full RBAC for the session lifetime): the web console holds it in the browser session and the
`apiclient` (test harness / automation) keeps it in memory, each re-validating it against `/auth/me`
before use (discarding a stale/revoked one); `apiclient` also **refuses to send credentials over
plaintext `http` to a non-loopback host** (no TLS yet) unless explicitly run with `--insecure` for
trusted-network dev. (The retired PySide6 desktop console's OS-keyring token cache is an accepted
retirement loss — BACKLOG #103.)
user's full RBAC for the session lifetime), so where each client keeps it matters. **At least** these
three shipped clients hold one:

| Client | Where the token lives | Outlives the process that got it? |
|---|---|---|
| Web console | the browser session | no |
| `apiclient` (test harness / automation) | process memory | no |
| VS Code extension (`ide/src/auth.ts`) | VS Code **SecretStorage**, keyed by engine URL | **yes** |

The console and `apiclient` each re-validate against `/auth/me` before use, discarding a stale or
revoked token; `apiclient` also **refuses to send credentials over plaintext `http` to a non-loopback
host** (no TLS yet) unless explicitly run with `--insecure` for trusted-network dev. The extension is
the one holder that puts the credential in **durable, OS-managed** storage. It persists across VS Code
restarts, so on a deploying site the token would outlive the editor window that acquired it and stay
usable until the session's own idle or absolute timeout retires it server-side. The extension clears
its copy on sign-out (revoking the session on the engine first, where the engine is reachable) and on
a 401 from a request that carried the token; a background timer never clears it, because a request the
session took no part in is not evidence about the session. (The retired PySide6 desktop console's
OS-keyring token cache is an accepted retirement loss — BACKLOG #103. That retired one *instance* of
durable token storage, not the shape: the extension's SecretStorage cache is a live one.)

### Directory session reconciliation — propagating an AD disable (ADR 0079 mechanism 2)

Expand Down Expand Up @@ -1528,15 +1542,24 @@ Users and admins can see and revoke individual sessions (ASVS 7.5.2 / 7.4.5):
flagged). The session `id` is the session's `token_hash` (a one-way hash of the opaque token, safe to
expose).
- **`DELETE /me/sessions/{id}`** — revoke one of **your own** sessions (ownership-checked: another
user's id returns 404, never revealing or touching it).
user's id returns 404, never revealing or touching it). **Gated on a fresh password re-proof bound
to the `session_terminate` action** (ASVS 7.5.2 — see the route table above): the sign-in you
already hold does not unlock a terminate, and the grant is single-use.
- **`DELETE /me/sessions`** — "sign out everywhere else": revoke all your sessions except the current.
Same `session_terminate` re-proof gate.
- **`DELETE /users/{id}/sessions`** (`users:manage`) — admin force-sign-out of a user (offboarding /
suspected compromise).

The two self-service terminates are **password-only** step-ups deliberately: a second-factor gate
would deadlock an MFA-required-but-unenrolled operator out of revoking their own sessions.

Every targeted revoke is audited (`auth.session_revoked`, with scope + actor). The **web console** surfaces
this: an **Active sessions…** view in the account menu lists your sessions and offers per-session
revoke + "sign out everywhere else" (the current session is shown but only revocable via *Sign out*),
and the **Users** page has a **Revoke sessions** action for admin force-sign-out.
revoke + "sign out everywhere else". The console renders **no Revoke button on the current session**,
so the list cannot leave the operator mid-request; *Sign out* is the console's way to end it. That is
a property of the **page**, not of the API — `DELETE /me/sessions/{id}` checks ownership only, so it
accepts the caller's own current session id and revokes it. The **Users** page has a **Revoke
sessions** action for admin force-sign-out.

### Security-event notifications (WP-L3-05, ASVS 6.3.5 / 6.3.7)

Expand Down
1 change: 1 addition & 0 deletions docs/adr/0091-element-centric-connections-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ A **read-only** graph canvas of the estate as an **on-demand editor-area webview
## Deferred follow-ups (all closed 2026-07-12)

- [x] **Live decorations** — shipped 2026-07-12 (owner authorized 2026-07-12). Opt-in (`messagefoundry.liveStatus.enabled`, default off; `intervalSeconds` ≥ 5) poll of the engine's `GET /connections` (`Permission.MONITORING_READ` — the lowest read tier, same as the Console dashboard; no new engine route or permission) feeding status + message counts as description suffixes on inbound/outbound rows (`ide/src/liveStatus.ts` poller, pure `liveStatusModel.ts` row aggregation, `graphModel.ts` suffix rendering, `GraphProvider.setRuntime`). Destination rows (one per inbound→outbound edge) aggregate per outbound: counts sum, worst-severity status wins. Auth is **passive**: the poll reuses the SecretStorage session Stage → Promote signed in with (`auth.peekToken` — never prompts from a timer) behind the SEC-005 host gate; 401 clears the dead session, 401/403/unreachable all degrade silently to undecorated rows (a dev engine embedded `allow_no_auth` serves it tokenless). Counts + status words only — never message content. *Residual gap, honest by design:* router/handler rows stay undecorated — the engine keys its stage metrics by connection, so no per-router/per-handler runtime counter exists to show.
- **Amendment (2026-09-06, BACKLOG #1145) — the poll is now TOKENLESS, and the "auth is passive" sentence above describes only what shipped in July.** Passive there meant *never prompts*; it was not passive about the session's idle clock. `GET /connections` is gated by plain `require(Permission.MONITORING_READ)`, and `require()` resolves the bearer with `identity_for_token(bearer_token(request))` — the default `activity=True`, which refreshes the session's idle clock. Every other bearer-gated `require*` factory in `api/security.py` delegates to `require()`; `require_service_cert` is the one that does not, and it is cert-only, never a bearer. So a bearer on a 5-to-10-second timer refreshed the idle clock on every tick and would make the engine's 30-minute idle timeout unreachable for as long as a VS Code window stayed open (CWE-613). That is the rule `ide/src/statusBar.ts` already stated for its own 15s poll, so the two files gave opposite answers to one question. The bearer is now driven by `liveStatusModel.LIVE_STATUS_PLAN` (`authenticated: false`, asserted in CI) instead of an unconditional `peekToken`, and the 401 branch no longer clears the cached session — a tokenless 401 is the route demanding auth, not evidence the session died. **Accepted cost:** against an auth-enabled engine the rows stay undecorated, so decorations now land only where `/connections` answers tokenless (a dev or embedded engine under `allow_no_auth`). The setting ships OFF by default; the full monitor remains the web console at `/ui`, which reads the same data under `activity=False`. Making a bearer safe here would need an engine-side passive-read surface — no header, query parameter or route lets a *client* ask for `activity=False` today — which is a separate change.
- [x] **Refresh** — shipped 2026-07-12: a config-dir `FileSystemWatcher` (`**/*.py`, `connections.toml`, `codesets/**/*.csv`; `ide/src/configWatcher.ts`) and the save handler both funnel into one debounced (750 ms, `configRefresh.ts` `RefreshCoalescer`) validate + graph + code-sets refresh, so an external edit (git pull, another tool) refreshes without double-firing against the save path; exec-gated (ADR 0035) exactly like the save handler. A `configDir` resolving outside the workspace folder is not watched (graceful: manual refresh still works); the watcher rebuilds when the setting changes.
- [x] **D3 go gate:** resolved — **owner go 2026-07-12**. v1 scope: a **focus-first** (always opened focused on one element — tree selection, context menu, or QuickPick), **hop-bounded** (1–3, default 2, BFS both directions), **node-capped** (150, farthest hop dropped deterministically, truncation surfaced), strictly **read-only** editor-area webview (`ide/src/wiringMap.ts` over the pure `ide/src/wiringMapModel.ts`); dynamic elements render a synthetic "?" stub, edges carry their D1 provenance (solid = declared/literal, dashed = heuristic). A **whole-estate render is deliberately absent** (the Dagster ~200-node degradation vs the ADR 0052 target) — an unfocused build is legal only under the node cap. No drag-drop, no editing (#26 untouched).

Expand Down
13 changes: 10 additions & 3 deletions harness/load/connscale/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from enum import StrEnum
from pathlib import Path

from messagefoundry.api.models import PendingApprovalResponse
from messagefoundry.apiclient import ApiError, EngineClient

_WINDOWS = sys.platform == "win32"
Expand Down Expand Up @@ -679,11 +680,17 @@ def _as_int(text: str) -> int | None:

def time_reload(client: EngineClient, config_dir: str | None) -> float | None:
"""Time one ``reload_config(config_dir)`` round-trip in seconds (wall #5), or ``None`` if the
reload errors. Synchronous — the runner calls it in ``run_in_executor`` (off the event loop, like
the rest of the engine polling). ``config_dir=None`` reloads the server's startup --config dir."""
reload errors or was held. Synchronous — the runner calls it in ``run_in_executor`` (off the
event loop, like the rest of the engine polling). ``config_dir=None`` reloads the server's
startup --config dir."""
t0 = time.perf_counter()
try:
client.reload_config(config_dir)
result = client.reload_config(config_dir)
except ApiError:
return None
if isinstance(result, PendingApprovalResponse):
# Dual-control held the reload (ASVS 2.3.5): no graph was swapped, so the elapsed time is
# the cost of parking an approval, not the O(connections) reload this wall measures. Report
# no sample rather than a fast one that would read as a reload getting cheaper.
return None
return time.perf_counter() - t0
20 changes: 19 additions & 1 deletion harness/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
fmt_ts,
)
from harness._login import LoginDialog
from messagefoundry.api.models import ConnectionRow, DeadLetterRow
from messagefoundry.api.models import ConnectionRow, DeadLetterRow, PendingApprovalResponse
from messagefoundry.apiclient import ApiError, EngineClient

_DEFAULT_URL = "http://127.0.0.1:8765"
Expand Down Expand Up @@ -486,6 +486,9 @@ def _purge_outbound(self) -> None:
except ApiError as exc:
self._set_status(str(exc), error=True)
return
if isinstance(result, PendingApprovalResponse):
self._set_held_status(result)
return
self._set_status(f"purged {result.cancelled} queued delivery(ies) from {key[2]}")

def _replay_selected_dead(self) -> None:
Expand Down Expand Up @@ -525,6 +528,9 @@ def _do_replay(self, *, channel_id: str | None, destination_name: str | None) ->
except ApiError as exc:
self._set_status(str(exc), error=True)
return
if isinstance(result, PendingApprovalResponse):
self._set_held_status(result)
return
self._set_status(f"re-queued {result.requeued} dead-lettered delivery(ies)")

def _reload_config(self) -> None:
Expand All @@ -534,13 +540,25 @@ def _reload_config(self) -> None:
except ApiError as exc:
self._set_status(str(exc), error=True)
return
if isinstance(result, PendingApprovalResponse):
self._set_held_status(result)
return
self._set_status(
f"reloaded: {result.inbound} inbound · {result.outbound} outbound · "
f"{result.routers} routers · {result.handlers} handlers"
)

# --- status --------------------------------------------------------------

def _set_held_status(self, held: PendingApprovalResponse) -> None:
"""Report a dual-control hold (ASVS 2.3.5): the engine accepted the request and did NOT run
it, so this is neither a failure nor a completed action. A distinct second approver must
release it, and the requester cannot release their own."""
self._set_status(
f"{held.operation} held for a second approver (approval {held.approval_id}): "
f"{held.detail}"
)

def _set_status(self, message: str, *, error: bool = False) -> None:
self._status.setStyleSheet("color: #c62828;" if error else "")
self._status.setText(message)
Loading
Loading