From 0a9b730da1edd7495a9a9d2e98ef66c479cc0ccf Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 28 Jul 2026 12:59:26 +0700 Subject: [PATCH] feat(viewer): show Past Runs without email verification Browsing local scan history no longer requires the work-email + OTP step. The /api/runs list and individual other-run data now unlock on the per-process session capability alone (drops auth.is_verified from the gate); the session-cookie check is preserved so an exposed --host port still cannot enumerate or read history. Email verification remains required only for emailing encrypted reports (auth.py unchanged). Backend-only; the prebuilt SPA renders the unlocked payload, no rebuild needed. Renames build_runs_payload kwarg verified->unlocked and updates the two viewer integration tests to pin is_verified=False, proving the email term is no longer on the history path. --- strix/interface/viewer/server.py | 48 +++++++++++++++++--------------- tests/test_viewer.py | 30 +++++++++----------- tests/test_viewer_runs_gating.py | 12 ++++---- 3 files changed, 46 insertions(+), 44 deletions(-) diff --git a/strix/interface/viewer/server.py b/strix/interface/viewer/server.py index 37bfd7cf3..652fe6349 100644 --- a/strix/interface/viewer/server.py +++ b/strix/interface/viewer/server.py @@ -78,15 +78,17 @@ def run_list_entry(run_dir: Path) -> dict[str, Any]: } -def build_runs_payload(base_dir: Path, *, verified: bool) -> dict[str, Any]: - """The /api/runs payload. Gates the run list behind email verification. +def build_runs_payload(base_dir: Path, *, unlocked: bool) -> dict[str, Any]: + """The /api/runs payload. Gates the run list behind the caller's authorization. - The count is always advertised so the UI can tease the history, but the - entries only appear once the viewer is verified. + ``unlocked`` is the caller's access decision (today: holds this process's + session capability). When locked, the count is still advertised so the UI can + tease the history, but the entries only appear once unlocked. Email + verification is no longer part of this decision. """ run_dirs = _iter_run_dirs(base_dir) count = len(run_dirs) - if not verified: + if not unlocked: return {"locked": True, "count": count, "runs": []} return {"locked": False, "count": count, "runs": [run_list_entry(d) for d in run_dirs]} @@ -235,13 +237,17 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]: def _handle_api(self, path: str, query: dict[str, list[str]]) -> None: # The launched run is always viewable with no verification. The - # cross-run history list (/api/runs) unlocks its entries only for a - # caller that holds this process's session capability *and* is email - # verified, so merely reaching an exposed --host port never leaks the - # run list (the payload still advertises the count as a teaser). + # cross-run history list (/api/runs) unlocks its entries for any + # caller that holds this process's session capability, so merely + # reaching an exposed --host port never leaks the run list (the + # cookie is handed only to the browser this process bootstrapped). + # Email verification is intentionally NOT required here: browsing + # local run history is a local convenience, and gating it behind an + # OTP was unnecessary friction. Verification still guards emailing an + # encrypted report (see _handle_report_send). if path == "/api/runs": - unlocked = self._has_session() and auth.is_verified() - payload = build_runs_payload(state.base_dir, verified=unlocked) + unlocked = self._has_session() + payload = build_runs_payload(state.base_dir, unlocked=unlocked) self._send_json(HTTPStatus.OK, payload) return if path == "/api/capabilities": @@ -261,17 +267,15 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]: return # The launched run is always viewable. Any *other* run's data is part - # of the gated history: it needs this process's session capability - # (so merely reaching an exposed --host port is not enough) *and* - # email verification -- otherwise knowing a run name would leak its - # metadata, vulnerabilities, report, and transcript. - if run_dir.resolve() != state.run_dir.resolve(): - if not self._has_session(): - self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"}) - return - if not auth.is_verified(): - self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"}) - return + # of the history: it needs this process's session capability (so + # merely reaching an exposed --host port is not enough) -- knowing a + # run name is not sufficient to leak its metadata, vulnerabilities, + # report, or transcript. Email verification is intentionally NOT + # required, matching the /api/runs list: once the list is browsable, + # its entries must be openable without an OTP step. + if run_dir.resolve() != state.run_dir.resolve() and not self._has_session(): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"}) + return if path == "/api/run": self._send_json(HTTPStatus.OK, read_run_summary(run_dir)) diff --git a/tests/test_viewer.py b/tests/test_viewer.py index eb13cc1fc..0120e5511 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -549,15 +549,16 @@ def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.Monkey httpd.server_close() -def test_historical_run_data_requires_verification( +def test_historical_run_data_requires_session_only( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z") _make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z") _bundle(tmp_path, monkeypatch) - verified = {"value": False} - monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: verified["value"]) + # Email verification is pinned False for the whole test to prove it is no + # longer consulted for browsing history: only the session capability is. + monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False) httpd, url, token = serve(launched, open_browser=False) try: @@ -567,31 +568,27 @@ def test_historical_run_data_requires_verification( cookie = _session_cookie(url, token) - # A different run needs the session capability first: a cookie-less - # caller is forbidden even once the machine is verified. - verified["value"] = True + # A different run still needs the session capability first: a cookie-less + # caller is forbidden. assert _get_status(f"{url}/api/run?run=other") == 403 - # With the cookie but not verified, the history gate returns 401. - verified["value"] = False - assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 401 - - # With both the cookie and verification, the historical run resolves. - verified["value"] = True + # With the cookie, the historical run resolves even though the machine is + # NOT email-verified -- the email gate has been removed. assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 200 finally: httpd.shutdown() httpd.server_close() -def test_runs_list_requires_session_and_verification( +def test_runs_list_requires_session_only( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z") _make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z") _bundle(tmp_path, monkeypatch) - monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True) + # Pinned unverified to prove the list unlocks on the session capability alone. + monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False) def _runs(cookie: str | None) -> dict[str, object]: headers = {"Cookie": cookie} if cookie else {} @@ -601,14 +598,13 @@ def test_runs_list_requires_session_and_verification( httpd, url, token = serve(launched, open_browser=False) try: - # A cookie-less caller (even with the machine verified) only sees the - # teaser count, never the run entries. + # A cookie-less caller only sees the teaser count, never the run entries. payload = _runs(None) assert payload["locked"] is True assert payload["count"] == 2 assert payload["runs"] == [] - # With the session cookie and verification, the entries unlock. + # With the session cookie the entries unlock -- no email verification. payload = _runs(_session_cookie(url, token)) assert payload["locked"] is False assert {r["name"] for r in payload["runs"]} == {"launched", "other"} # type: ignore[attr-defined] diff --git a/tests/test_viewer_runs_gating.py b/tests/test_viewer_runs_gating.py index 817804372..576c451b4 100644 --- a/tests/test_viewer_runs_gating.py +++ b/tests/test_viewer_runs_gating.py @@ -30,23 +30,25 @@ def _make_run(base: Path, name: str, *, severity: str = "high") -> Path: return run_dir -def test_runs_payload_locked_when_unverified(tmp_path: Path) -> None: +def test_runs_payload_locked_when_no_session(tmp_path: Path) -> None: base = tmp_path / "strix_runs" _make_run(tmp_path, "alpha") _make_run(tmp_path, "beta") - payload = build_runs_payload(base, verified=False) + # No session capability -> locked, but the count is still advertised. + payload = build_runs_payload(base, unlocked=False) assert payload["locked"] is True assert payload["count"] == 2 assert payload["runs"] == [] -def test_runs_payload_lists_when_verified(tmp_path: Path) -> None: +def test_runs_payload_lists_when_unlocked(tmp_path: Path) -> None: base = tmp_path / "strix_runs" _make_run(tmp_path, "alpha", severity="critical") _make_run(tmp_path, "beta", severity="info") - payload = build_runs_payload(base, verified=True) + # A session-holding caller unlocks the list -- no email verification needed. + payload = build_runs_payload(base, unlocked=True) assert payload["locked"] is False assert payload["count"] == 2 assert len(payload["runs"]) == 2 @@ -59,7 +61,7 @@ def test_runs_payload_lists_when_verified(tmp_path: Path) -> None: def test_runs_payload_empty_base(tmp_path: Path) -> None: - payload = build_runs_payload(tmp_path / "strix_runs", verified=True) + payload = build_runs_payload(tmp_path / "strix_runs", unlocked=True) assert payload == {"locked": False, "count": 0, "runs": []}