From 56b6733c2224fc0700b9612d1e2f23adaf99ccc5 Mon Sep 17 00:00:00 2001 From: junjslee Date: Tue, 21 Jul 2026 10:43:25 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(viewer):=20control=20plane=20=E2=80=94?= =?UTF-8?q?=20Tier=201/2=20actions=20behind=20a=20defense=20ladder,=20Tier?= =?UTF-8?q?=203=20stays=20deliberate=20(Event=20175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-ratified boundary: Tier 1 diagnostics (doctor, kernel verify, docs lint, index --check) and Tier 2 reversible maintenance (sync, docs index, doc-map rebuild) become buttons; Tier 3 governance mutations (kernel update, review verdicts, profile acks, rigor/mode) render as copyable commands and have NO endpoints — the kernel's deliberate-friction thesis applied to its own UI; the absence of the endpoint is the enforcement (positive system). Read-only-on-unauthenticated-localhost was a conscious trade; mutation is not. Defense ladder on every control POST, each rung tested independently: (1) control hard-disabled on non-loopback binds, (2) Host-header loopback allowlist (DNS-rebinding defense), (3) per-server-start session token minted with secrets and templated into index.html — foreign origins can neither read it nor send the custom header without a preflight this server never grants. Single-flight lock (409 busy), 120s timeout, 64KB output cap with a visible truncation marker. Live-verified end-to-end through the authenticated path: doctor 115ms, kernel_verify 44ms, docs_lint 66ms, docmap_rebuild 188ms, sync 99ms — and sync fired from the viewer during a live governed session left doctor at 12/0/0. Suite 1821+91. --- docs/COMMANDS.md | 2 +- src/episteme/viewer/control.py | 168 +++++++++++++++++++++++++++++++++ src/episteme/viewer/index.html | 85 +++++++++++++++++ src/episteme/viewer/server.py | 64 +++++++++++++ tests/test_viewer_control.py | 164 ++++++++++++++++++++++++++++++++ 5 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 src/episteme/viewer/control.py create mode 100644 tests/test_viewer_control.py 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..2da3547 --- /dev/null +++ b/src/episteme/viewer/control.py @@ -0,0 +1,168 @@ +"""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: + if name == "docmap_rebuild": + code, output = _run_docmap_rebuild(cwd) + else: + try: + proc = subprocess.run( + _cli_argv(action.argv), + capture_output=True, + text=True, + timeout=ACTION_TIMEOUT_S, + cwd=cwd, + ) + code = proc.returncode + output = (proc.stdout or "") + (proc.stderr or "") + except subprocess.TimeoutExpired: + return { + "error": "timeout", + "name": name, + "timeout_s": ACTION_TIMEOUT_S, + } + truncated = len(output.encode("utf-8", errors="replace")) > OUTPUT_CAP_BYTES + if truncated: + output = output[:OUTPUT_CAP_BYTES] + TRUNCATION_MARKER + return { + "name": name, + "tier": action.tier, + "exit_code": code, + "output": output, + "truncated": truncated, + } + finally: + _run_lock.release() + + +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..240b9da 100644 --- a/src/episteme/viewer/server.py +++ b/src/episteme/viewer/server.py @@ -34,12 +34,34 @@ 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) + +#: Set False by serve() on any non-loopback bind; the control plane is then +#: hard-disabled regardless of tokens (read-only exposure was a conscious +#: trade; remote mutation is not). +CONTROL_ENABLED = True + + +def _host_allowed(host_header: str) -> bool: + host = (host_header or "").strip().rsplit(":", 1)[0].lower() + # rsplit breaks bracketed IPv6 ([::1]:port → "[::1]"); compare whole. + if host_header and host_header.strip().lower().startswith("[::1]"): + return True + 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 +233,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 +279,42 @@ 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 + if 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..501a9b8 --- /dev/null +++ b/tests/test_viewer_control.py @@ -0,0 +1,164 @@ +"""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_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): + 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() + + 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): + status, body = self._post( + "/api/control/docs_lint", token=viewer_server.SESSION_TOKEN + ) + self.assertEqual(status, 200) + self.assertEqual(body["exit_code"], 0) + self.assertIn("clean", body["output"]) + + +if __name__ == "__main__": + unittest.main() From 25bc9e99620463c6b99d38be2979a2ddca8c8064 Mon Sep 17 00:00:00 2001 From: junjslee Date: Tue, 21 Jul 2026 10:52:21 -0400 Subject: [PATCH 2/2] fix(viewer): fail-closed kill-switch, clean JSON on action failure, timing-safe token, byte-accurate cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review (0 blockers) applied: - CONTROL_ENABLED now defaults False — a module preaching fail-closed had its kill-switch fail-open; only serve() enables control after confirming a loopback bind. Fresh-interpreter test pins the default (reload() in-test would remint the shared server's token). - run_action wraps the action body in a broad guard: docmap_rebuild in a non-git project escaped as a raw 500 traceback (reproduced by review); every failure is now structured JSON and the lock provably frees. - hmac.compare_digest on the session token (hardening; 256-bit + loopback made == practically safe, but a security boundary compares safely). - Byte-accurate output cap (char slice let multibyte output exceed 64KB ~4x) and process-group kill on timeout (sync/doctor grandchildren would orphan). - _host_allowed: exact [::1] forms only — the prefix test accepted '[::1].evil.com' (not browser-producible; defense-in-depth). - Tier-1 end-to-end test decoupled from live lint state. Residual (review nit 7, accepted): the tier label is self-asserted — no test binds label to effect; the guard is TIER3_CATALOG cross-check + PR review. Suite 1823+91. --- src/episteme/viewer/control.py | 82 +++++++++++++++++++++++++--------- src/episteme/viewer/server.py | 27 +++++++---- tests/test_viewer_control.py | 42 ++++++++++++++++- 3 files changed, 119 insertions(+), 32 deletions(-) diff --git a/src/episteme/viewer/control.py b/src/episteme/viewer/control.py index 2da3547..0e4e294 100644 --- a/src/episteme/viewer/control.py +++ b/src/episteme/viewer/control.py @@ -119,28 +119,34 @@ def run_action(name: str, cwd: str) -> dict: if not _run_lock.acquire(blocking=False): return {"error": "busy"} try: - if name == "docmap_rebuild": - code, output = _run_docmap_rebuild(cwd) - else: - try: - proc = subprocess.run( - _cli_argv(action.argv), - capture_output=True, - text=True, - timeout=ACTION_TIMEOUT_S, - cwd=cwd, - ) - code = proc.returncode - output = (proc.stdout or "") + (proc.stderr or "") - except subprocess.TimeoutExpired: - return { - "error": "timeout", - "name": name, - "timeout_s": ACTION_TIMEOUT_S, - } - truncated = len(output.encode("utf-8", errors="replace")) > OUTPUT_CAP_BYTES + # 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: - output = output[:OUTPUT_CAP_BYTES] + TRUNCATION_MARKER + # 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, @@ -152,6 +158,40 @@ def run_action(name: str, cwd: str) -> dict: _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 { diff --git a/src/episteme/viewer/server.py b/src/episteme/viewer/server.py index 240b9da..eca05a9 100644 --- a/src/episteme/viewer/server.py +++ b/src/episteme/viewer/server.py @@ -49,17 +49,21 @@ SESSION_TOKEN = secrets.token_urlsafe(32) -#: Set False by serve() on any non-loopback bind; the control plane is then -#: hard-disabled regardless of tokens (read-only exposure was a conscious -#: trade; remote mutation is not). -CONTROL_ENABLED = True +#: 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: - host = (host_header or "").strip().rsplit(":", 1)[0].lower() - # rsplit breaks bracketed IPv6 ([::1]:port → "[::1]"); compare whole. - if host_header and host_header.strip().lower().startswith("[::1]"): + 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") @@ -294,8 +298,13 @@ def do_POST(self) -> None: if not _host_allowed(self.headers.get("Host", "")): self._send_json(403, {"error": "host_not_allowed"}) return - # 3. session token - if self.headers.get("X-Episteme-Token", "") != SESSION_TOKEN: + # 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/"):] diff --git a/tests/test_viewer_control.py b/tests/test_viewer_control.py index 501a9b8..e60f92e 100644 --- a/tests/test_viewer_control.py +++ b/tests/test_viewer_control.py @@ -37,6 +37,34 @@ 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: @@ -69,6 +97,11 @@ def test_foreign_hosts_fail(self): 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) @@ -78,6 +111,7 @@ def setUpClass(cls): 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( @@ -152,12 +186,16 @@ def test_index_page_embeds_the_session_token(self): 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["exit_code"], 0) - self.assertIn("clean", body["output"]) + self.assertEqual(body["name"], "docs_lint") + self.assertIn(body["exit_code"], (0, 1)) + self.assertTrue(body["output"].strip()) if __name__ == "__main__":