diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 78c4c10..1e0b7c8 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -46,7 +46,7 @@ Scope key: | `episteme detect [path]` | project | Score which harness type fits the project. | | `episteme harness {list,apply}` | project | List available harnesses; apply one to a project. A harness defines execution profile + workflow constraints for a project type. | | `episteme worktree` | project | Create a git worktree for a bounded task in the current repo. | -| `episteme viewer` | project | Live local governance dashboard (E174): global operator-home state + current-project surface/doc-map/staleness + the DOC ADVISORY feed, polling every 3s at `localhost:37776`; auto-opens the browser (`--no-open` to suppress). Menu-bar companion: `tools/xbar/episteme.30s.sh`. | +| `episteme viewer` | project | Live local governance dashboard (E174) + control plane (E175): global operator-home state, current-project surface/doc-map/staleness, the DOC ADVISORY feed (3s poll, `localhost:37776`, auto-opens browser; `--no-open`). Actions: Tier 1 diagnostics + Tier 2 reversible maintenance as token-authenticated buttons (loopback-only, Host-checked); Tier 3 governance mutations render as copyable commands — deliberately not executable from the UI. Menu-bar companion: `tools/xbar/episteme.30s.sh`. | | `episteme capture` | project | Draft a `reasoning-surface.json` skeleton from unstructured text (Slack thread, PR desc, ticket, email). Reads stdin. | ## Framework internals diff --git a/src/episteme/viewer/control.py b/src/episteme/viewer/control.py new file mode 100644 index 0000000..0e4e294 --- /dev/null +++ b/src/episteme/viewer/control.py @@ -0,0 +1,208 @@ +"""Viewer control plane — operator-ratified action tiers (Event 175). + +Positive system: an action exists ONLY if enumerated here. The tier boundary +is governance, not convenience — Tier 3 (governance mutations: rigor/mode +toggles, kernel manifest updates, review verdicts, profile acks) is +deliberately ABSENT from the executable registry and ships as a copyable +command catalog instead. The kernel's thesis is deliberate friction on +consequential ops; a UI that one-clicks them would bypass the exact ceremony +it visualizes. The absence of the endpoint IS the enforcement. + +Security model (all enforced in server.py, tested in +tests/test_viewer_control.py): control is loopback-only (hard-disabled on any +other bind), every POST carries the per-server-start session token, and the +Host header must be in the loopback allowlist (DNS-rebinding defense). +""" + +from __future__ import annotations + +import subprocess +import sys +import threading +from dataclasses import dataclass +from typing import List, Optional + +#: Hard cap on captured action output returned to the client. +OUTPUT_CAP_BYTES = 65_536 +#: Per-action subprocess timeout (sync can take a few seconds; doctor too). +ACTION_TIMEOUT_S = 120 + +#: Marker appended when output is truncated — the UI must show the cut, +#: never render a silently shortened result as complete. +TRUNCATION_MARKER = "\n… [output truncated at 64KB]" + + +@dataclass(frozen=True) +class Action: + name: str # registry key, also the URL segment + tier: int # 1 = diagnostic, 2 = reversible maintenance (confirm-gated in UI) + label: str # button text + argv: tuple # episteme CLI args + description: str + + +#: The executable registry. Tier 3 is NOT here — by design, not omission. +ACTIONS: dict = { + a.name: a + for a in ( + Action("doctor", 1, "doctor", ("doctor",), + "Verify runtime wiring (read-only)"), + Action("kernel_verify", 1, "kernel verify", ("kernel", "verify"), + "Check managed kernel files against the manifest (read-only)"), + Action("docs_lint", 1, "docs lint", ("docs", "lint"), + "Validate lifecycle markers on tracked docs (read-only)"), + Action("docs_index_check", 1, "docs index --check", ("docs", "index", "--check"), + "Verify the generated docs index is current (read-only)"), + Action("sync", 2, "sync", ("sync",), + "Propagate kernel memory + governance policies to agent surfaces"), + Action("docs_index_write", 2, "docs index", ("docs", "index"), + "Regenerate the docs/README.md index from lifecycle markers"), + Action("docmap_rebuild", 2, "rebuild doc map", (), + "Rebuild the code→doc reverse-index cache for this project"), + ) +} + +#: Tier 3 — rendered by the UI as copyable commands, never executable here. +#: Each entry names the ceremony the CLI flow enforces that a button would skip. +TIER3_CATALOG: List[dict] = [ + {"command": "episteme kernel update", + "why_cli": "re-baselines the kernel integrity manifest — verify per-file provenance first"}, + {"command": "episteme review", + "why_cli": "spot-check verdicts are operator judgment; each entry deserves the full context"}, + {"command": "episteme profile audit ack ", + "why_cli": "acknowledging profile drift changes derived gate knobs"}, + {"command": "episteme deferred list", + "why_cli": "closing deferred discoveries requires verification against reality (kernel rule 11)"}, +] + +_run_lock = threading.Lock() + + +def _cli_argv(extra: tuple) -> List[str]: + # `python -m episteme` has no __main__; spawn the CLI through -c. Trailing + # args land in the child's sys.argv[1:], which argparse consumes normally. + return [ + sys.executable, + "-c", + "import sys; from episteme.cli import main; sys.exit(main())", + *extra, + ] + + +def _run_docmap_rebuild(cwd: str) -> "tuple[int, str]": + from pathlib import Path + + from episteme import doc_references + + root = Path(cwd) + cache = root / ".episteme" / "cache" / "doc_map.json" + try: + cache.unlink(missing_ok=True) + except OSError: + pass + index = doc_references.cached_reverse_index(root) + return 0, ( + f"doc map rebuilt: {len(index)} targets, " + f"{sum(len(v) for v in index.values())} edges" + ) + + +def run_action(name: str, cwd: str) -> dict: + """Execute one registered action; single-flight; output capped. + + Returns a JSON-ready dict. ``busy`` (HTTP 409 upstream) when another + action is running — one operator, one mutation at a time. + """ + action: Optional[Action] = ACTIONS.get(name) + if action is None: + return {"error": "unknown_action", "name": name} + if not _run_lock.acquire(blocking=False): + return {"error": "busy"} + try: + # Broad guard (review finding): the in-process branch raised + # RuntimeError in a non-git project and escaped as a raw 500; every + # action failure must come back as clean JSON — a control plane that + # tracebacks teaches the operator to distrust its buttons. + try: + if name == "docmap_rebuild": + code, output = _run_docmap_rebuild(cwd) + else: + code, output = _run_subprocess(action.argv, cwd) + except _ActionTimeout: + return { + "error": "timeout", + "name": name, + "timeout_s": ACTION_TIMEOUT_S, + } + except Exception as exc: + return { + "error": "action_failed", + "name": name, + "detail": f"{type(exc).__name__}: {exc}"[:500], + } + data = output.encode("utf-8", errors="replace") + truncated = len(data) > OUTPUT_CAP_BYTES + if truncated: + # Byte-accurate cut (review nit: char slicing let multibyte + # output exceed the advertised cap ~4x); decode-with-ignore + # drops at most one split codepoint at the boundary. + output = data[:OUTPUT_CAP_BYTES].decode("utf-8", "ignore") + TRUNCATION_MARKER + return { + "name": name, + "tier": action.tier, + "exit_code": code, + "output": output, + "truncated": truncated, + } + finally: + _run_lock.release() + + +class _ActionTimeout(Exception): + pass + + +def _run_subprocess(argv_tail: tuple, cwd: str) -> "tuple[int, str]": + """Run one CLI action in its own session; kill the whole group on timeout. + + ``subprocess.run(timeout=...)`` reaps only the direct child — a git + process spawned by sync/doctor would orphan (review nit). Popen + + start_new_session lets the timeout path kill the process group. + """ + import os + import signal + + proc = subprocess.Popen( + _cli_argv(argv_tail), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=cwd, + start_new_session=True, + ) + try: + output, _ = proc.communicate(timeout=ACTION_TIMEOUT_S) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (OSError, ProcessLookupError): + proc.kill() + proc.wait() + raise _ActionTimeout() from None + return proc.returncode, output or "" + + +def catalog() -> dict: + """The full action surface for the UI: executable tiers + Tier 3 display.""" + return { + "actions": [ + { + "name": a.name, + "tier": a.tier, + "label": a.label, + "description": a.description, + } + for a in sorted(ACTIONS.values(), key=lambda a: (a.tier, a.name)) + ], + "tier3": TIER3_CATALOG, + } diff --git a/src/episteme/viewer/index.html b/src/episteme/viewer/index.html index 1c7534c..0b798a3 100644 --- a/src/episteme/viewer/index.html +++ b/src/episteme/viewer/index.html @@ -33,6 +33,21 @@ .feed td.t { white-space: nowrap; color: var(--dim); } .empty { color: var(--dim); padding: 8px 0; } .q { color: var(--dim); font-size: 12px; margin-top: 8px; overflow-wrap: anywhere; } + .btnrow { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; } + .btnrow button { background: transparent; border: 1px solid var(--edge); color: var(--text); + font: 12px var(--mono); padding: 6px 12px; border-radius: 6px; cursor: pointer; } + .btnrow button:hover { border-color: var(--accent); } + .btnrow button:disabled { opacity: .4; cursor: wait; } + .btnrow button.t2 { border-style: dashed; } + .actout { background: var(--bg); border: 1px solid var(--edge); border-radius: 6px; + padding: 10px 12px; margin: 8px 0; max-height: 320px; overflow: auto; + font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; } + .actout.err { border-color: var(--bad); } + .t3 { margin-top: 10px; } + .t3 h3 { font-size: 11px; color: var(--dim); font-weight: 500; margin-bottom: 6px; } + .t3 .cmd { display: flex; gap: 10px; align-items: baseline; padding: 2px 0; } + .t3 code { color: var(--warn); cursor: copy; } + .t3 .why { color: var(--dim); font-size: 12px; } @@ -63,6 +78,17 @@

Global — operator home

noise watch…
+
+

Actions

+
+
+ +
+

Operator-gated (run in a terminal — deliberate friction is the point)

+
+
+
+

Doc advisories — docs tracked as code changes

No advisories logged yet in this project. Edit an implementation file that a doc cites and it will appear here.
@@ -75,8 +101,66 @@

Doc advisories — docs tracked as code changes

diff --git a/src/episteme/viewer/server.py b/src/episteme/viewer/server.py index b8337b8..eca05a9 100644 --- a/src/episteme/viewer/server.py +++ b/src/episteme/viewer/server.py @@ -34,12 +34,38 @@ from typing import Any, Callable from urllib.parse import urlsplit +from episteme.viewer import control as _control from episteme.viewer import live as _live REPO_ROOT = Path(__file__).resolve().parents[3] VIEWER_DIR = Path(__file__).resolve().parent +# Per-server-start session token (E175). Injected into index.html at serve +# time; every control POST must echo it in X-Episteme-Token. A foreign page +# cannot read it (no CORS grants) and cannot send the custom header +# cross-origin without a preflight this server never approves. +import secrets + +SESSION_TOKEN = secrets.token_urlsafe(32) + +#: Fail-CLOSED default (review finding: a kill-switch must not default open). +#: Only serve() enables control, and only after confirming a loopback bind; +#: a handler constructed any other way (tests, embedding) gets no control +#: plane unless it opts in explicitly. +CONTROL_ENABLED = False + + +def _host_allowed(host_header: str) -> bool: + header = (host_header or "").strip().lower() + # Exact bracketed-IPv6 forms only — a prefix test accepted + # "[::1].evil.com" (review nit; not browser-producible, but the + # allowlist should not rely on that). + if header == "[::1]" or header.startswith("[::1]:"): + return True + host = header.rsplit(":", 1)[0] + return host in ("127.0.0.1", "localhost") + def _project_root() -> Path: """Git top-level of the cwd the viewer was launched from, else the cwd.""" @@ -211,8 +237,18 @@ def do_GET(self) -> None: self._send_json(200, routes[path]()) return + if path == "/api/control/catalog": + payload = _control.catalog() + payload["control_enabled"] = CONTROL_ENABLED + self._send_json(200, payload) + return + if path == "/" or path == "/index.html": html_body = _safe_read(VIEWER_DIR / "index.html") + if html_body: + # Token templating (E175): the page is the only place the + # session token exists client-side. + html_body = html_body.replace("{{EPISTEME_TOKEN}}", SESSION_TOKEN) self._send_text(200, html_body or "

episteme viewer

index.html missing

") return @@ -247,10 +283,47 @@ def do_GET(self) -> None: self._send_text(404, "not found", "text/plain") + def do_POST(self) -> None: + parsed = urlsplit(self.path) + path = parsed.path + if not path.startswith("/api/control/"): + self._send_json(404, {"error": "not_found"}) + return + # Defense ladder (E175) — each rung tested independently: + # 1. control hard-disabled on non-loopback binds + if not CONTROL_ENABLED: + self._send_json(403, {"error": "control_disabled_non_loopback"}) + return + # 2. Host allowlist (DNS-rebinding defense) + if not _host_allowed(self.headers.get("Host", "")): + self._send_json(403, {"error": "host_not_allowed"}) + return + # 3. session token (timing-safe compare — hardening; the token is + # 256-bit and loopback-only, but a security boundary compares safely) + import hmac + + if not hmac.compare_digest( + self.headers.get("X-Episteme-Token", ""), SESSION_TOKEN + ): + self._send_json(403, {"error": "token_mismatch"}) + return + name = path[len("/api/control/"):] + result = _control.run_action(name, cwd=str(PROJECT_ROOT)) + if result.get("error") == "unknown_action": + self._send_json(404, result) + elif result.get("error") == "busy": + self._send_json(409, result) + elif result.get("error") == "timeout": + self._send_json(504, result) + else: + self._send_json(200, result) + def serve(host: str = "127.0.0.1", port: int = 37776, open_browser: bool = True) -> int: + global CONTROL_ENABLED server = ThreadingHTTPServer((host, port), _Handler) loopback = host in ("127.0.0.1", "localhost", "::1") + CONTROL_ENABLED = loopback # A non-loopback bind serves the operator's core_question, absolute # home/project paths, and edit activity over UNAUTHENTICATED HTTP — # never do it silently (review finding, E174). diff --git a/tests/test_viewer_control.py b/tests/test_viewer_control.py new file mode 100644 index 0000000..e60f92e --- /dev/null +++ b/tests/test_viewer_control.py @@ -0,0 +1,202 @@ +"""Event 175 — viewer control plane: defense ladder, tiers, execution. + +Every rung of the defense ladder is tested independently against the REAL +server (ephemeral port): non-loopback disable, Host allowlist, session +token. The tier boundary is tested as an ABSENCE: Tier 3 commands must not +exist in the executable registry — the missing endpoint is the enforcement. +""" + +from __future__ import annotations + +import json +import sys +import threading +import unittest +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer +from unittest.mock import patch + +from episteme.viewer import control, server as viewer_server + + +class RegistryTests(unittest.TestCase): + def test_positive_system_tiers(self): + tiers = {a.tier for a in control.ACTIONS.values()} + self.assertEqual(tiers, {1, 2}) + + def test_tier3_commands_are_not_executable(self): + # The governance boundary: every Tier 3 catalog command must be + # absent from the executable registry. + executable_cmds = {" ".join(a.argv) for a in control.ACTIONS.values()} + for entry in control.TIER3_CATALOG: + bare = entry["command"].removeprefix("episteme ").split(" <")[0] + self.assertNotIn(bare, executable_cmds, entry["command"]) + + def test_unknown_action_is_refused(self): + result = control.run_action("rm_rf_everything", cwd=".") + self.assertEqual(result["error"], "unknown_action") + + def test_action_failure_returns_clean_json_not_a_traceback(self): + # Review disconfirmation: docmap_rebuild in a non-git cwd raised + # RuntimeError straight through do_POST as a raw 500. Every action + # failure must come back as structured JSON, and the lock must be + # free afterward. + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + result = control.run_action("docmap_rebuild", cwd=tmp) + self.assertEqual(result["error"], "action_failed") + self.assertIn("RuntimeError", result["detail"]) + self.assertTrue(control._run_lock.acquire(blocking=False)) + control._run_lock.release() + + def test_control_enabled_defaults_fail_closed(self): + # The kill-switch must default OFF; only serve() may enable it after + # confirming a loopback bind (review finding: fail-open posture). + # Fresh interpreter — reload() here would remint the shared server's + # SESSION_TOKEN under the HTTP tests' feet. + import subprocess as sp + + probe = sp.run( + [sys.executable, "-c", + "from episteme.viewer import server; print(server.CONTROL_ENABLED)"], + capture_output=True, text=True, timeout=30, + ) + self.assertEqual(probe.stdout.strip(), "False", probe.stderr) + + def test_busy_lock_single_flight(self): + acquired = control._run_lock.acquire() + try: + result = control.run_action("doctor", cwd=".") + self.assertEqual(result["error"], "busy") + finally: + if acquired: + control._run_lock.release() + + def test_truncation_is_marked_never_silent(self): + oversized = control.OUTPUT_CAP_BYTES + 100 + with patch.object(control, "_cli_argv") as argv: + argv.return_value = [sys.executable, "-c", f"print('x' * {oversized})"] + result = control.run_action("doctor", cwd=".") + self.assertTrue(result["truncated"]) + self.assertIn(control.TRUNCATION_MARKER.strip(), result["output"][-100:]) + + +class HostAllowlistTests(unittest.TestCase): + def test_loopback_hosts_pass(self): + for h in ("127.0.0.1:37776", "localhost:37776", "127.0.0.1", "localhost", + "[::1]:37776"): + self.assertTrue(viewer_server._host_allowed(h), h) + + def test_foreign_hosts_fail(self): + for h in ("evil.example.com:37776", "192.168.1.7:37776", "", "evil.example.com"): + self.assertFalse(viewer_server._host_allowed(h), h) + + +class ControlHttpTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Fail-closed default means a bare handler has no control plane; + # these tests opt in the way serve() does on a confirmed loopback + # bind, and restore the default afterward. + cls._prev_enabled = viewer_server.CONTROL_ENABLED + viewer_server.CONTROL_ENABLED = True + cls._server = ThreadingHTTPServer(("127.0.0.1", 0), viewer_server._Handler) + cls._port = cls._server.server_address[1] + cls._thread = threading.Thread(target=cls._server.serve_forever, daemon=True) + cls._thread.start() + + @classmethod + def tearDownClass(cls): + cls._server.shutdown() + cls._server.server_close() + viewer_server.CONTROL_ENABLED = cls._prev_enabled + + def _post(self, path: str, token: str | None = None, host: str | None = None): + req = urllib.request.Request( + f"http://127.0.0.1:{self._port}{path}", method="POST", data=b"" + ) + if token is not None: + req.add_header("X-Episteme-Token", token) + if host is not None: + # urllib refuses to override Host via add_header on some paths; + # set it unredirected to simulate a DNS-rebound request. + req.add_unredirected_header("Host", host) + try: + with urllib.request.urlopen(req, timeout=10) as res: + return res.status, json.loads(res.read().decode()) + except urllib.error.HTTPError as err: + return err.code, json.loads(err.read().decode()) + + def test_missing_token_is_403(self): + status, body = self._post("/api/control/doctor") + self.assertEqual(status, 403) + self.assertEqual(body["error"], "token_mismatch") + + def test_wrong_token_is_403(self): + status, body = self._post("/api/control/doctor", token="stale-token") + self.assertEqual(status, 403) + self.assertEqual(body["error"], "token_mismatch") + + def test_rebound_host_is_403_even_with_valid_token(self): + status, body = self._post( + "/api/control/doctor", + token=viewer_server.SESSION_TOKEN, + host="evil.example.com", + ) + self.assertEqual(status, 403) + self.assertEqual(body["error"], "host_not_allowed") + + def test_unknown_action_is_404_with_valid_token(self): + status, body = self._post( + "/api/control/kernel_update", token=viewer_server.SESSION_TOKEN + ) + self.assertEqual(status, 404) + self.assertEqual(body["error"], "unknown_action") + + def test_control_disabled_flag_beats_valid_token(self): + original = viewer_server.CONTROL_ENABLED + viewer_server.CONTROL_ENABLED = False + try: + status, body = self._post( + "/api/control/doctor", token=viewer_server.SESSION_TOKEN + ) + self.assertEqual(status, 403) + self.assertEqual(body["error"], "control_disabled_non_loopback") + finally: + viewer_server.CONTROL_ENABLED = original + + def test_catalog_lists_tiers_and_flag(self): + with urllib.request.urlopen( + f"http://127.0.0.1:{self._port}/api/control/catalog", timeout=10 + ) as res: + body = json.loads(res.read().decode()) + self.assertIn("control_enabled", body) + self.assertTrue(any(a["tier"] == 1 for a in body["actions"])) + self.assertTrue(any(a["tier"] == 2 for a in body["actions"])) + self.assertTrue(body["tier3"]) + + def test_index_page_embeds_the_session_token(self): + with urllib.request.urlopen( + f"http://127.0.0.1:{self._port}/", timeout=10 + ) as res: + page = res.read().decode() + self.assertIn(viewer_server.SESSION_TOKEN, page) + self.assertNotIn("{{EPISTEME_TOKEN}}", page) + + def test_tier1_action_runs_end_to_end(self): + # Asserts the authenticated pipe works — action ran, produced output, + # and exited with a lint verdict (0 or 1). Pinning exit==0 would make + # a control-plane test fail for a docs reason (review nit). + status, body = self._post( + "/api/control/docs_lint", token=viewer_server.SESSION_TOKEN + ) + self.assertEqual(status, 200) + self.assertEqual(body["name"], "docs_lint") + self.assertIn(body["exit_code"], (0, 1)) + self.assertTrue(body["output"].strip()) + + +if __name__ == "__main__": + unittest.main()