b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
96 lines
3.9 KiB
Python
96 lines
3.9 KiB
Python
"""Guardrail: dashboard plugins must NOT read the session token directly.
|
|
|
|
The dashboard host exposes a sanctioned, gated-mode-aware auth surface on the
|
|
plugin SDK (``window.__HERMES_PLUGIN_SDK__``): ``fetchJSON`` (JSON REST),
|
|
``authedFetch`` (uploads / blob downloads), and ``buildWsUrl`` /
|
|
``buildWsAuthParam`` (WebSockets). These handle BOTH dashboard auth modes —
|
|
loopback (``X-Hermes-Session-Token`` header) and gated OAuth
|
|
(``hermes_session_at`` cookie / single-use ``?ticket=``).
|
|
|
|
Plugins that hand-roll ``fetch`` / ``WebSocket`` and read
|
|
``window.__HERMES_SESSION_TOKEN__`` directly send an empty token in gated mode
|
|
and 401/1008. That bug shipped in the kanban and achievements plugins and was
|
|
invisible until the dashboard ran gated on hosted Fly agents.
|
|
|
|
This test fails if any bundled plugin's frontend reads the token global
|
|
directly, forcing new/edited plugins through the SDK surface instead. It is
|
|
the enforcement half of the "single sanctioned auth surface" design — the SDK
|
|
helpers are the carrot, this test is the stick.
|
|
|
|
If you have a legitimate reason to reference the token name (e.g. a comment
|
|
explaining why NOT to use it), add the file to ``_ALLOWED_FILES`` with a note.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Repo root: tests/plugins/<this file> → ../../
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
_PLUGINS_DIR = _REPO_ROOT / "plugins"
|
|
|
|
# The forbidden global. Reading it directly bypasses the gated-mode auth path.
|
|
_FORBIDDEN = "__HERMES_SESSION_TOKEN__"
|
|
|
|
# Files explicitly allowed to mention the token (none today). Map path →
|
|
# reason so the allowance is self-documenting if one is ever needed.
|
|
_ALLOWED_FILES: dict[str, str] = {}
|
|
|
|
|
|
def _plugin_frontend_bundles() -> list[Path]:
|
|
"""Every plugin-shipped JS bundle the dashboard loads into the browser."""
|
|
if not _PLUGINS_DIR.is_dir():
|
|
return []
|
|
# Plugin dashboards live at plugins/<name>/dashboard/dist/*.js
|
|
return sorted(_PLUGINS_DIR.glob("*/dashboard/dist/*.js"))
|
|
|
|
|
|
def test_there_are_plugin_bundles_to_check() -> None:
|
|
"""Sanity: the glob actually finds the bundles, so a future layout change
|
|
doesn't silently turn this guard into a no-op."""
|
|
bundles = _plugin_frontend_bundles()
|
|
names = {b.parent.parent.parent.name for b in bundles}
|
|
# kanban + hermes-achievements are bundled today; assert at least one is
|
|
# found so the guard can't pass vacuously.
|
|
assert bundles, "no plugin dashboard bundles found — glob/layout drift?"
|
|
assert names, "could not resolve plugin names from bundle paths"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bundle",
|
|
_plugin_frontend_bundles(),
|
|
ids=lambda p: str(p.relative_to(_REPO_ROOT)),
|
|
)
|
|
def test_plugin_bundle_does_not_read_session_token(bundle: Path) -> None:
|
|
rel = str(bundle.relative_to(_REPO_ROOT))
|
|
text = bundle.read_text(encoding="utf-8", errors="replace")
|
|
|
|
if rel in _ALLOWED_FILES:
|
|
return # explicitly allowed (with a documented reason)
|
|
|
|
# Only flag CODE reads of the token global, not mentions in ``//`` comments
|
|
# (e.g. a comment explaining why the SDK helper is used instead). A line is
|
|
# a code read if it contains the global and the global appears before any
|
|
# ``//`` comment marker on that line.
|
|
offending: list[str] = []
|
|
for i, line in enumerate(text.splitlines(), start=1):
|
|
idx = line.find(_FORBIDDEN)
|
|
if idx == -1:
|
|
continue
|
|
comment_idx = line.find("//")
|
|
in_comment = comment_idx != -1 and comment_idx < idx
|
|
if not in_comment:
|
|
offending.append(f" {i}: {line.strip()}")
|
|
|
|
if not offending:
|
|
return
|
|
|
|
pytest.fail(
|
|
f"{rel} reads {_FORBIDDEN} directly — this bypasses gated-mode auth "
|
|
f"and 401/1008s on OAuth-gated dashboards. Use the plugin SDK instead: "
|
|
f"SDK.fetchJSON (JSON), SDK.authedFetch (uploads/downloads), or "
|
|
f"SDK.buildWsUrl (WebSockets). Offending lines:\n" + "\n".join(offending)
|
|
)
|