-
Notifications
You must be signed in to change notification settings - Fork 26
feat(web): vouch review-ui — browser-based review console (mvp slice) #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -784,5 +784,81 @@ def serve(transport: str) -> None: | |
| run_jsonl() | ||
|
|
||
|
|
||
| # --- review-ui: browser-based review console ----------------------------- | ||
|
|
||
|
|
||
| @cli.command(name="review-ui") | ||
| @click.option("--bind", "bind", default="127.0.0.1:7780", show_default=True, | ||
| help="host:port to bind. Refuses 0.0.0.0 in this MVP slice " | ||
| "(auth lands with the HTTP-transport feature).") | ||
| @click.option("--kb", "kb_root", default=None, | ||
| type=click.Path(exists=True, file_okay=False), | ||
| help="KB root (defaults to the nearest .vouch/ above cwd).") | ||
| @click.option("--open-browser/--no-open-browser", default=True, show_default=True, | ||
| help="Open the browser to the queue on startup.") | ||
| def review_ui(bind: str, kb_root: str | None, open_browser: bool) -> None: | ||
| """Run the browser-based review console. | ||
|
|
||
| \b | ||
| Examples: | ||
| vouch review-ui # bind 127.0.0.1:7780, open browser | ||
| vouch review-ui --bind 127.0.0.1:8000 | ||
| vouch review-ui --no-open-browser # ssh / headless friendly | ||
| """ | ||
| if ":" not in bind: | ||
| raise click.ClickException( | ||
| f"--bind must be host:port (got {bind!r})" | ||
| ) | ||
| host, _, port_str = bind.rpartition(":") | ||
| try: | ||
| port = int(port_str) | ||
| except ValueError as e: | ||
| raise click.ClickException(f"invalid port in --bind: {port_str!r}") from e | ||
|
|
||
| # The full design (issue #194) refuses 0.0.0.0 without --auth bearer. | ||
| # The MVP slice does not ship the Bearer-auth layer yet (depends on | ||
| # the HTTP-transport feature), so we refuse non-localhost binds | ||
| # outright — a clearer error than silently exposing an unauthenticated | ||
| # approve surface on the network. | ||
| if host not in ("127.0.0.1", "localhost", "::1"): | ||
| raise click.ClickException( | ||
| f"--bind {bind!r}: review-ui is localhost-only in the MVP slice. " | ||
| "Bearer-auth + non-loopback binding land alongside the HTTP " | ||
| "transport feature." | ||
| ) | ||
|
Comment on lines
+808
to
+828
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle IPv6 localhost addresses with brackets. The current parsing uses 🛡️ Proposed fix to strip brackets from IPv6 addresses host, _, port_str = bind.rpartition(":")
+ # Strip brackets from IPv6 addresses like [::1]:7780
+ if host.startswith("[") and host.endswith("]"):
+ host = host[1:-1]
try:
port = int(port_str)🤖 Prompt for AI Agents |
||
|
|
||
| try: | ||
| from . import web as web_pkg | ||
| except ImportError as e: | ||
| raise click.ClickException(str(e)) from e | ||
|
|
||
| try: | ||
| app = web_pkg.create_app(kb_root) | ||
| except (FileNotFoundError, RuntimeError) as e: | ||
| raise click.ClickException(str(e)) from e | ||
|
|
||
| try: | ||
| import uvicorn | ||
| except ImportError as e: | ||
| raise click.ClickException( | ||
| "vouch review-ui needs the [web] extra. " | ||
| "Install with: pip install 'vouch-kb[web]'" | ||
| ) from e | ||
|
|
||
| if open_browser: | ||
| # Lazy-import webbrowser; some CI envs (headless) don't have a default | ||
| # browser configured and webbrowser.open() returns False rather than | ||
| # raising — that's fine, the URL is also printed to stdout. | ||
| import threading | ||
| import webbrowser | ||
| url = f"http://{host}:{port}/" | ||
| click.echo(f"vouch review-ui running at {url}") | ||
| threading.Timer(0.5, lambda: webbrowser.open(url)).start() | ||
| else: | ||
| click.echo(f"vouch review-ui running at http://{host}:{port}/") | ||
|
Comment on lines
+854
to
+858
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the accepted loopback bind is Useful? React with 👍 / 👎. |
||
|
|
||
| uvicorn.run(app, host=host, port=port, log_level="info") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| cli() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| """Browser-based review console for vouch. | ||
|
|
||
| The web layer is a *viewport* over the existing kb.* surface — every action | ||
| (approve, reject) goes through the same ``vouch.proposals`` / ``vouch.storage`` | ||
| code path as the CLI, so the audit log is identical regardless of surface. | ||
|
|
||
| The dependencies (fastapi, jinja2) live behind the ``[web]`` extra so the | ||
| base install stays light. ``vouch review-ui`` produces an actionable | ||
| ``ImportError`` line if the extra is missing. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
|
|
||
| def _require_web_extra() -> None: | ||
| """Fail with a clean message if fastapi/jinja2 aren't installed.""" | ||
| missing: list[str] = [] | ||
| try: | ||
| import fastapi # noqa: F401 | ||
| except ImportError: | ||
| missing.append("fastapi") | ||
| try: | ||
| import jinja2 # noqa: F401 | ||
| except ImportError: | ||
| missing.append("jinja2") | ||
| if missing: | ||
| raise ImportError( | ||
| "vouch review-ui needs the [web] extra. " | ||
| "Install with: pip install 'vouch-kb[web]' " | ||
| f"(missing: {', '.join(missing)})" | ||
| ) | ||
|
|
||
|
|
||
| def create_app(kb_root: str | None = None): # type: ignore[no-untyped-def] | ||
| """Build the FastAPI app for a given KB root. Lazy-imports the web stack.""" | ||
| _require_web_extra() | ||
| from .server import build_app | ||
|
|
||
| return build_app(kb_root) | ||
|
|
||
|
|
||
| __all__ = ["create_app"] |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,174 @@ | ||||||||||||||||||||||||||||||||||
| """FastAPI app for the review console. | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| MVP slice: queue + claim detail + approve/reject + audit timeline. No | ||||||||||||||||||||||||||||||||||
| WebSocket, no Bearer auth — those land alongside the HTTP transport (#1) | ||||||||||||||||||||||||||||||||||
| and multi-dim scopes (#2). Localhost-only by default. | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| The web layer is intentionally thin: every approve/reject goes through | ||||||||||||||||||||||||||||||||||
| ``vouch.proposals.approve`` / ``vouch.proposals.reject`` so the audit log | ||||||||||||||||||||||||||||||||||
| is identical regardless of whether the action came from the CLI or the UI. | ||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||||||
| from typing import Any | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from fastapi import FastAPI, Form, HTTPException, Request | ||||||||||||||||||||||||||||||||||
| from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse | ||||||||||||||||||||||||||||||||||
| from fastapi.staticfiles import StaticFiles | ||||||||||||||||||||||||||||||||||
| from fastapi.templating import Jinja2Templates | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| from .. import audit as audit_mod | ||||||||||||||||||||||||||||||||||
| from .. import proposals as proposals_mod | ||||||||||||||||||||||||||||||||||
| from ..models import ProposalStatus | ||||||||||||||||||||||||||||||||||
| from ..storage import ArtifactNotFoundError, KBStore, discover_root | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| _MODULE_DIR = Path(__file__).resolve().parent | ||||||||||||||||||||||||||||||||||
| _TEMPLATES_DIR = _MODULE_DIR / "templates" | ||||||||||||||||||||||||||||||||||
| _STATIC_DIR = _MODULE_DIR / "static" | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def _is_review_event(name: str) -> bool: | ||||||||||||||||||||||||||||||||||
| """The audit log carries every mutation; the timeline only shows | ||||||||||||||||||||||||||||||||||
| review-gate decisions (approve / reject) and claim lifecycle moves.""" | ||||||||||||||||||||||||||||||||||
| if name.startswith("proposal.") and name.endswith((".approve", ".reject")): | ||||||||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||||||||
| return name in {"claim.supersede", "claim.contradict", | ||||||||||||||||||||||||||||||||||
| "claim.archive", "claim.confirm"} | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def _whoami() -> str: | ||||||||||||||||||||||||||||||||||
| """Reviewer identity. Without the Bearer-auth layer from #1 this falls | ||||||||||||||||||||||||||||||||||
| back to the same env var the CLI uses, so audit-log attribution stays | ||||||||||||||||||||||||||||||||||
| consistent across surfaces.""" | ||||||||||||||||||||||||||||||||||
| return os.environ.get("VOUCH_AGENT", "web-reviewer") | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+42
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make The comment states that reviewer identity should match the CLI behavior for consistent audit logs, but the implementation differs:
If 🔧 Proposed fix to match CLI behavior+import getpass
+
def _whoami() -> str:
"""Reviewer identity. Without the Bearer-auth layer from `#1` this falls
back to the same env var the CLI uses, so audit-log attribution stays
consistent across surfaces."""
- return os.environ.get("VOUCH_AGENT", "web-reviewer")
+ return (
+ os.environ.get("VOUCH_AGENT")
+ or os.environ.get("VOUCH_USER")
+ or getpass.getuser()
+ )📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a human files a proposal via the CLI without Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def _proposal_preview(payload: dict[str, Any]) -> str: | ||||||||||||||||||||||||||||||||||
| """One-line preview shown in the queue. Mirrors the CLI's `pending` output.""" | ||||||||||||||||||||||||||||||||||
| for key in ("text", "title", "name"): | ||||||||||||||||||||||||||||||||||
| value = payload.get(key) | ||||||||||||||||||||||||||||||||||
| if value: | ||||||||||||||||||||||||||||||||||
| return str(value).strip().splitlines()[0][:160] | ||||||||||||||||||||||||||||||||||
| return "—" | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| def build_app(kb_root: str | None = None) -> FastAPI: | ||||||||||||||||||||||||||||||||||
| """FastAPI app bound to a KB root. ``kb_root`` defaults to the nearest | ||||||||||||||||||||||||||||||||||
| ``.vouch/`` discovered by walking up from ``cwd``.""" | ||||||||||||||||||||||||||||||||||
| start = Path(kb_root).resolve() if kb_root else None | ||||||||||||||||||||||||||||||||||
| # Resolve once at construction so every request hits the same store. | ||||||||||||||||||||||||||||||||||
| # discover_root walks up looking for .vouch/ — the same behavior as | ||||||||||||||||||||||||||||||||||
| # every other vouch CLI command. Failing here means a clearer error | ||||||||||||||||||||||||||||||||||
| # than a 500 on the first request. | ||||||||||||||||||||||||||||||||||
| root = discover_root(start) | ||||||||||||||||||||||||||||||||||
| store = KBStore(root) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) | ||||||||||||||||||||||||||||||||||
| app = FastAPI(title="vouch review-ui", docs_url=None, redoc_url=None) | ||||||||||||||||||||||||||||||||||
| app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @app.get("/healthz") | ||||||||||||||||||||||||||||||||||
| def healthz() -> dict[str, Any]: | ||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||
| "ok": True, | ||||||||||||||||||||||||||||||||||
| "kb": str(store.root), | ||||||||||||||||||||||||||||||||||
| "pending": len(store.list_proposals(ProposalStatus.PENDING)), | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @app.get("/", response_class=HTMLResponse) | ||||||||||||||||||||||||||||||||||
| def queue(request: Request) -> Any: | ||||||||||||||||||||||||||||||||||
| pending = store.list_proposals(ProposalStatus.PENDING) | ||||||||||||||||||||||||||||||||||
| items = [ | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| "id": p.id, | ||||||||||||||||||||||||||||||||||
| "kind": p.kind.value, | ||||||||||||||||||||||||||||||||||
| "proposed_by": p.proposed_by, | ||||||||||||||||||||||||||||||||||
| "proposed_at": p.proposed_at.isoformat(timespec="seconds"), | ||||||||||||||||||||||||||||||||||
| "preview": _proposal_preview(p.payload), | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| for p in pending | ||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||
| return templates.TemplateResponse( | ||||||||||||||||||||||||||||||||||
| request, | ||||||||||||||||||||||||||||||||||
| "queue.html", | ||||||||||||||||||||||||||||||||||
| {"items": items, "count": len(items)}, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @app.get("/claim/{proposal_id}", response_class=HTMLResponse) | ||||||||||||||||||||||||||||||||||
| def claim_detail(request: Request, proposal_id: str) -> Any: | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| pr = store.get_proposal(proposal_id) | ||||||||||||||||||||||||||||||||||
| except ArtifactNotFoundError as e: | ||||||||||||||||||||||||||||||||||
| raise HTTPException(status_code=404, detail=str(e)) from e | ||||||||||||||||||||||||||||||||||
| return templates.TemplateResponse( | ||||||||||||||||||||||||||||||||||
| request, | ||||||||||||||||||||||||||||||||||
| "claim.html", | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| "proposal": pr.model_dump(mode="json"), | ||||||||||||||||||||||||||||||||||
| "preview": _proposal_preview(pr.payload), | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @app.post("/approve/{proposal_id}") | ||||||||||||||||||||||||||||||||||
| def approve(proposal_id: str, reason: str | None = Form(default=None)) -> Any: | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+115
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because these approve/reject routes are unauthenticated and accept plain form POSTs, a malicious web page can submit a cross-site form to a locally running review UI if it has or can infer a proposal id, causing an approval or rejection under the user's local reviewer identity. Localhost binding does not stop browser CSRF; add an origin/token check for these mutation routes while keeping the no-JS form flow. Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| proposals_mod.approve( | ||||||||||||||||||||||||||||||||||
| store, proposal_id, approved_by=_whoami(), reason=reason | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| except (proposals_mod.ProposalError, ArtifactNotFoundError) as e: | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For proposals that were valid when filed but become invalid before approval, such as a cited source/evidence or a page's referenced claim being deleted, Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||
| raise HTTPException(status_code=400, detail=str(e)) from e | ||||||||||||||||||||||||||||||||||
| return RedirectResponse(url="/", status_code=303) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @app.post("/reject/{proposal_id}") | ||||||||||||||||||||||||||||||||||
| def reject(proposal_id: str, reason: str = Form(...)) -> Any: | ||||||||||||||||||||||||||||||||||
| if not reason.strip(): | ||||||||||||||||||||||||||||||||||
| raise HTTPException(status_code=400, detail="reason is required") | ||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||
| proposals_mod.reject( | ||||||||||||||||||||||||||||||||||
| store, proposal_id, rejected_by=_whoami(), reason=reason | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
| except (proposals_mod.ProposalError, ArtifactNotFoundError) as e: | ||||||||||||||||||||||||||||||||||
| raise HTTPException(status_code=400, detail=str(e)) from e | ||||||||||||||||||||||||||||||||||
| return RedirectResponse(url="/", status_code=303) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @app.get("/audit", response_class=HTMLResponse) | ||||||||||||||||||||||||||||||||||
| def audit(request: Request, limit: int = 100) -> Any: | ||||||||||||||||||||||||||||||||||
| events = list(audit_mod.read_events(store.kb_dir)) | ||||||||||||||||||||||||||||||||||
| events.reverse() # newest first | ||||||||||||||||||||||||||||||||||
| filtered = [e for e in events if _is_review_event(e.event)][:limit] | ||||||||||||||||||||||||||||||||||
| rows = [ | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| "id": e.id, | ||||||||||||||||||||||||||||||||||
| "event": e.event, | ||||||||||||||||||||||||||||||||||
| "actor": e.actor, | ||||||||||||||||||||||||||||||||||
| "object_ids": e.object_ids, | ||||||||||||||||||||||||||||||||||
| "at": e.created_at.isoformat(timespec="seconds"), | ||||||||||||||||||||||||||||||||||
| "reason": e.data.get("reason"), | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| for e in filtered | ||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||
| return templates.TemplateResponse( | ||||||||||||||||||||||||||||||||||
| request, | ||||||||||||||||||||||||||||||||||
| "audit.html", | ||||||||||||||||||||||||||||||||||
| {"rows": rows, "count": len(rows)}, | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| @app.get("/api/pending") | ||||||||||||||||||||||||||||||||||
| def api_pending() -> JSONResponse: | ||||||||||||||||||||||||||||||||||
| pending = store.list_proposals(ProposalStatus.PENDING) | ||||||||||||||||||||||||||||||||||
| return JSONResponse( | ||||||||||||||||||||||||||||||||||
| [ | ||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||
| "id": p.id, | ||||||||||||||||||||||||||||||||||
| "kind": p.kind.value, | ||||||||||||||||||||||||||||||||||
| "proposed_by": p.proposed_by, | ||||||||||||||||||||||||||||||||||
| "preview": _proposal_preview(p.payload), | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| for p in pending | ||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return app | ||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.