Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- name: install
run: |
python -m pip install --upgrade pip
pip install -e '.[dev]'
pip install -e '.[dev,web]'

- name: lint
run: python -m ruff check src tests
Expand Down
105 changes: 105 additions & 0 deletions docs/review-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# `vouch review-ui` — browser-based review console

The review gate is vouch's load-bearing primitive, but the terminal
(`vouch pending`, `vouch approve <id>`) only scales to a solo reviewer and a
small queue. `vouch review-ui` adds a browser viewport over the **same**
review surface — every approve/reject/contradict goes through the identical
`vouch.proposals` / `vouch.lifecycle` code path as the CLI, so the audit log is
the same regardless of which surface you used. The CLI is untouched.

Zero new on-disk schema. Zero new `kb.*` RPC methods. The web layer reads and
mutates through the existing `KBStore`.

## Run it

```bash
vouch review-ui # 127.0.0.1:7780, opens browser
vouch review-ui --bind 127.0.0.1:8000
vouch review-ui --no-open-browser # ssh / headless friendly

# team mode — a non-loopback bind REQUIRES a Bearer token:
vouch review-ui --bind 0.0.0.0:7780 --auth generate # mints + prints a token
VOUCH_REVIEW_TOKEN=… vouch review-ui --bind 0.0.0.0:7780 --auth env
vouch review-ui --bind 0.0.0.0:7780 --auth my-shared-secret --reviewer alice
```

A non-loopback bind without `--auth` is refused outright — we won't expose an
unauthenticated approve surface on the network.

## Authentication

When `--auth` is set, every route except `/healthz` and `/static` requires the
token. Credentials are accepted in three places, in priority order:

1. **`Authorization: Bearer <token>`** header — for the CLI, scripts, and API
callers.
2. **HttpOnly cookie** (`vouch_review_token`) — the steady-state browser path.
3. **`?token=` query string** — a *one-time bootstrap* only. On a `GET`, a
valid query token is moved into an HttpOnly, `SameSite=Strict` cookie and
the request is `303`-redirected to the same path with `?token=` stripped,
so the bare token never lingers in a bookmarkable URL or in access logs.

The token is never exposed to JavaScript (the cookie is HttpOnly), so an XSS
can't read it. Token comparisons are constant-time (`secrets.compare_digest`)
to avoid a timing oracle. `secure` is not set on the cookie by default so the
localhost-first (plain-http) flow works; terminate TLS at a proxy and have the
proxy mark the cookie `Secure` for an internet-facing deployment.

## Install

The web stack lives behind an optional extra so the base install stays light:

```bash
pip install 'vouch-kb[web]'
```

All HTML/CSS/JS ships **inside the wheel** — no `npm install`, no build step,
no CDN.

## Views

| Route | What it shows |
|-------|---------------|
| `/` | the pending queue, server-side paginated |
| `/claim/<id>` | one proposal's full payload + approve/reject |
| `/session/<id>` | every proposal from one agent run, grouped, with status |
| `/sources/<id>` | reverse index: which durable claims cite this source |
| `/audit` | the review-decision timeline |
| `/api/pending?page=N` | machine-readable paginated queue (`{count,page,pages,items}`) |
| `/healthz` | liveness + pending count + connected client count (always open) |
| `/ws` | realtime channel (see below) |

## Realtime sync

A single WebSocket channel per KB (`/ws`) keeps two reviewers in sync: when a
mutation lands, the handling route broadcasts a small `{"type":"refresh"}`
frame and every connected browser re-pulls the affected view within a second.
The frame is a *signal*, not data — the client re-fetches through the same
paginated routes, so there's exactly one rendering path. With `--auth` on, the
socket authenticates on the same-origin HttpOnly cookie the browser sends with
the handshake (a `?token=` query param is also accepted for non-browser
clients like the CLI).

## Progressive enhancement

Every action is a plain `<form method=post>`, so the gate works with
**JavaScript disabled** — you can read claims and approve/reject without it.
The WebSocket live-refresh and the keyboard shortcuts (`j`/`k` to move, `a`
to approve, `r` to focus the reject reason, `?` for help) are an additive
layer on top.

## Performance

The queue is paginated at the storage layer: only the requested page of
proposal files is parsed, not the whole queue. A 500-item queue's first page
renders in well under the 200 ms budget (~30 ms locally) because the other 450
files are never deserialised for that request.

## Out of scope

- Hosted "vouch cloud" — this is local/self-hosted only.
- Free-form claim editing in the browser; the gate is approve / reject /
contradict.
- Deleting durable claims from the UI — that stays CLI-only so a misclick
can't blow away history.
- Auth beyond Bearer (OAuth/SSO can layer on later).
18 changes: 18 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@ embeddings = [
"sentence-transformers>=3,<4",
"numpy>=1.26,<2",
]
web = [
"fastapi>=0.115,<1",
"jinja2>=3,<4",
"python-multipart>=0.0.9",
"uvicorn>=0.30,<1",
# uvicorn needs a websockets implementation to serve the /ws realtime
# channel; without it the WebSocket handshake 500s at runtime.
"websockets>=12,<16",
]
dev = [
"pytest>=9.0.3,<10",
"pytest-cov>=5,<6",
"mypy>=2.1.0",
"ruff>=0.15.13",
"types-pyyaml",
"httpx>=0.28,<1",
]
embeddings-fast = [
"fastembed>=0.3,<1",
Expand Down Expand Up @@ -94,3 +104,11 @@ ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["fastembed", "fastembed.*"]
ignore_missing_imports = true

# fastapi + jinja2 + uvicorn live behind the [web] extra; the base CI install
# only has [dev], so mypy can't resolve them when scanning src/vouch/web/.
# The web module guards its own imports with a clean ImportError message so
# the runtime story stays clean even without the extra.
[[tool.mypy.overrides]]
module = ["fastapi", "fastapi.*", "uvicorn", "uvicorn.*", "starlette", "starlette.*"]
ignore_missing_imports = true
123 changes: 123 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,5 +784,128 @@ def serve(transport: str) -> None:
run_jsonl()


# --- review-ui: browser-based review console -----------------------------


_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", ""})


def _resolve_auth_token(auth: str | None) -> str | None:
"""Turn the ``--auth`` option into a concrete token (or ``None``).

* ``None`` -> no auth (only allowed on a loopback bind).
* ``"generate"`` -> mint a random token and print it once.
* ``"env"`` -> read ``VOUCH_REVIEW_TOKEN`` from the environment.
* any other string -> use it verbatim as the bearer token.
"""
if auth is None:
return None
if auth == "generate":
import secrets
token = secrets.token_urlsafe(24)
click.echo(f"Generated review token: {token}")
return token
if auth == "env":
env_token = os.environ.get("VOUCH_REVIEW_TOKEN")
if not env_token:
raise click.ClickException(
"--auth env: VOUCH_REVIEW_TOKEN is not set in the environment"
)
return env_token
return auth


@cli.command(name="review-ui")
@click.option("--bind", "bind", default="127.0.0.1:7780", show_default=True,
help="host:port to bind. A non-loopback host (e.g. 0.0.0.0) "
"requires --auth so the approve surface isn't exposed "
"unauthenticated.")
@click.option("--auth", default=None,
help="Bearer-token mode: a literal token, 'generate' (mint a "
"random one and print it), or 'env' (read "
"VOUCH_REVIEW_TOKEN). Required for non-loopback binds.")
@click.option("--reviewer", "reviewer", default="web-reviewer", show_default=True,
help="Identity recorded in the audit log for token-authed "
"approve/reject decisions.")
@click.option("--page-size", default=None, type=int,
help="Queue page size (server-side pagination).")
@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, auth: str | None, reviewer: str, page_size: int | None,
kb_root: str | None, open_browser: bool) -> None:
"""Run the browser-based review console (issue #194).

\b
Examples:
vouch review-ui # 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
vouch review-ui --bind 0.0.0.0:7780 --auth generate # team mode
VOUCH_REVIEW_TOKEN=… vouch review-ui --bind 0.0.0.0:7780 --auth env
"""
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

token = _resolve_auth_token(auth)

# Refuse a non-loopback bind without a bearer token — exposing an
# unauthenticated approve surface on the network would let anyone on the
# LAN mutate the KB. Same posture as the HTTP transport (#1).
is_loopback = host in _LOOPBACK_HOSTS
if not is_loopback and token is None:
raise click.ClickException(
f"--bind {bind!r} is non-loopback; pass --auth (a token, "
"'generate', or 'env') so the approve surface requires a "
"Bearer token. Refusing to expose an unauthenticated gate."
)

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, auth_token=token, auth_label=reviewer, page_size=page_size
)
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

auth_note = " (Bearer auth on)" if token else ""
if open_browser and is_loopback:
# 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. When auth
# is on, hand the browser the token once via ?token= so it can stash it.
import threading
import webbrowser
suffix = f"?token={token}" if token else ""
url = f"http://{host}:{port}/{suffix}"
click.echo(f"vouch review-ui running at http://{host}:{port}/{auth_note}")
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
else:
click.echo(f"vouch review-ui running at http://{host}:{port}/{auth_note}")

uvicorn.run(app, host=host, port=port, log_level="info")


if __name__ == "__main__":
cli()
59 changes: 59 additions & 0 deletions src/vouch/web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Browser-based review console for vouch.

The web layer is a *viewport* over the existing kb.* surface — every action
(approve, reject, contradict) goes through the same ``vouch.proposals`` /
``vouch.lifecycle`` / ``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( # type: ignore[no-untyped-def]
kb_root: str | None = None,
*,
auth_token: str | None = None,
auth_label: str = "web-reviewer",
page_size: int | None = None,
):
"""Build the FastAPI app for a given KB root. Lazy-imports the web stack.

``auth_token`` enables the Bearer gate (every route requires the token);
``auth_label`` is the reviewer identity recorded in the audit log for
token-authenticated actions. ``page_size`` overrides queue pagination.
"""
_require_web_extra()
from .server import DEFAULT_PAGE_SIZE, AuthConfig, build_app

auth = AuthConfig(token=auth_token, label=auth_label)
return build_app(
kb_root,
auth=auth,
page_size=page_size if page_size is not None else DEFAULT_PAGE_SIZE,
)


__all__ = ["create_app"]
Loading
Loading