Skip to content
Open
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
20 changes: 20 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,19 @@ 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",
]
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 +101,16 @@ 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.*",
"jinja2", "jinja2.*",
"uvicorn", "uvicorn.*",
"starlette", "starlette.*",
]
ignore_missing_imports = true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
76 changes: 76 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle IPv6 localhost addresses with brackets.

The current parsing uses rpartition(":") which correctly extracts the port, but IPv6 addresses may be provided with brackets (e.g., [::1]:7780). In that case, host would be "[::1]" (with brackets) and would not match the allowlist check at line 823 that expects "::1".

🛡️ 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/cli.py` around lines 808 - 828, The host allowlist check fails for
bracketed IPv6 literals like "[::1]:7780"; after splitting bind into host and
port_str (the variables in this snippet), strip surrounding brackets from host
if host starts with "[" and ends with "]" (i.e., host = host[1:-1]) before
comparing against ("127.0.0.1", "localhost", "::1") so bracketed IPv6 localhost
is accepted.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Bracket IPv6 loopback addresses in generated URLs

When the accepted loopback bind is ::1:7780, this interpolation prints and opens http://::1:7780/, which URL parsers treat as an invalid authority instead of the IPv6 literal host. Users binding to IPv6 loopback need http://[::1]:7780/ (and the same formatting in the no-open branch) for the browser URL to work.

Useful? React with 👍 / 👎.


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


if __name__ == "__main__":
cli()
42 changes: 42 additions & 0 deletions src/vouch/web/__init__.py
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"]
174 changes: 174 additions & 0 deletions src/vouch/web/server.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make _whoami() consistent with the CLI version for audit-log attribution.

The comment states that reviewer identity should match the CLI behavior for consistent audit logs, but the implementation differs:

  • CLI (lines 75-84 in cli.py): VOUCH_AGENT or VOUCH_USER or getpass.getuser()
  • Web (line 46): VOUCH_AGENT or fallback "web-reviewer"

If VOUCH_USER is set but VOUCH_AGENT is not, the CLI will use VOUCH_USER while the web UI will use "web-reviewer", breaking attribution consistency.

🔧 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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")
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")
or os.environ.get("VOUCH_USER")
or getpass.getuser()
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/web/server.py` around lines 42 - 46, _update the _whoami function
to match the CLI's fallback chain: use VOUCH_AGENT if set, otherwise VOUCH_USER,
and finally fall back to the system user via getpass.getuser(); locate the
_whoami() function in server.py and replace the current single-env fallback
("web-reviewer") with the same sequence used by the CLI (VOUCH_AGENT or
VOUCH_USER or getpass.getuser()) so audit-log attribution is consistent across
web and CLI surfaces.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the same fallback identity as the CLI

When a human files a proposal via the CLI without VOUCH_AGENT, proposed_by is VOUCH_USER or the OS user, but the web UI falls back to the literal web-reviewer. In that default local workflow, approving from vouch review-ui no longer matches the proposer and bypasses proposals.approve's forbidden-self-approval guard, recording the wrong actor in the audit log. Mirror the CLI fallback or require an explicit reviewer so self-approval remains blocked.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add CSRF protection to mutation posts

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle invalidated proposals without a 500

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, proposals_mod.approve() can raise ValueError from the storage put_* methods. This handler only catches ProposalError and ArtifactNotFoundError, so the browser path returns an internal server error while the CLI's _cli_errors reports a clean user error for the same condition. Include ValueError in the handled errors.

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
Loading
Loading