From 692c10fe02c028ac29bb9cc1d7ba5ff667f81ae9 Mon Sep 17 00:00:00 2001 From: greatjourney589 Date: Wed, 3 Jun 2026 12:59:00 -0400 Subject: [PATCH 1/2] feat(serve): add HTTP/SSE transport with bearer-token auth --- proposals/README.md | 1 + proposals/VEP-0004-http-transport.md | 235 ++++++++++++++++++++++++++ pyproject.toml | 4 + src/vouch/auth.py | 132 +++++++++++++++ src/vouch/capabilities.py | 12 +- src/vouch/cli.py | 35 +++- src/vouch/http_server.py | 240 +++++++++++++++++++++++++++ src/vouch/models.py | 20 +++ tests/test_auth.py | 140 ++++++++++++++++ tests/test_http_server.py | 181 ++++++++++++++++++++ 10 files changed, 995 insertions(+), 5 deletions(-) create mode 100644 proposals/VEP-0004-http-transport.md create mode 100644 src/vouch/auth.py create mode 100644 src/vouch/http_server.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_http_server.py diff --git a/proposals/README.md b/proposals/README.md index b45c0d17..746f36b5 100644 --- a/proposals/README.md +++ b/proposals/README.md @@ -59,6 +59,7 @@ supersedes them. | [0001](VEP-0001-review-gate.md) | Review gate | final | 0.0.1 | | [0002](VEP-0002-jsonl-transport.md) | JSONL transport | final | 0.0.1 | | [0003](VEP-0003-content-hashed-sources.md) | Content-hashed sources | final | 0.0.1 | +| [0004](VEP-0004-http-transport.md) | HTTP transport with bearer-token auth and SSE | draft | — | ## Numbering diff --git a/proposals/VEP-0004-http-transport.md b/proposals/VEP-0004-http-transport.md new file mode 100644 index 00000000..b42be6d5 --- /dev/null +++ b/proposals/VEP-0004-http-transport.md @@ -0,0 +1,235 @@ +--- +vep: "0004" +title: HTTP transport with bearer-token auth and SSE streaming for vouch serve +author: greatjourney589 +status: draft +created: 2026-06-03 +landed-in: "" +supersedes: [] +superseded-by: "" +--- + +# VEP-0004: HTTP transport with bearer-token auth and SSE streaming + +## Summary + +Add a new `--transport http` mode to `vouch serve` that exposes the full +`kb.*` method surface over HTTP/JSON, adds a bearer-token auth layer with +per-token actor attribution, and streams audit events over Server-Sent +Events. Also adds `--transport mcp-http` for MCP over streamable-HTTP. +A companion `VOUCH_SERVER_URL` env var lets every existing CLI command +proxy through the HTTP server instead of operating on a local filesystem. + +## Motivation + +The two current transports — MCP stdio and JSONL stdio — require the agent +and the KB server to share a process or a pipe. This breaks down in three +common team setups: + +1. **Cross-machine review.** A CI runner proposes a claim; the human + reviewer wants to `vouch approve` it from their laptop. Today + `proposed/` is gitignored and local-only, so this is impossible. + +2. **Persistent shared sidecar.** Spawning a fresh MCP process per agent + session means each agent gets a cold FTS5 index and must re-warm + embeddings. A single long-lived HTTP server shares the warm index. + +3. **Subprocess-hostile runtimes.** Lambda, Cloud Run, and Replit cannot + `exec()` child processes; MCP stdio is unavailable. + +## Proposal + +### New CLI flags + +``` +vouch serve --transport http [--bind HOST:PORT] [--auth bearer|token-file|none] +vouch serve --transport mcp-http [--bind HOST:PORT] [--auth ...] +``` + +Defaults: `--bind 127.0.0.1:7749`, `--auth token-file`. + +### HTTP wire format + +``` +POST /kb/ +Content-Type: application/json +Authorization: Bearer + +{ ...params... } + +→ 200 OK { ...result... } +→ 4xx/5xx { "error": { "code": "...", "message": "..." } } +``` + +Error-code → HTTP status mapping: + +| vouch code | HTTP | +|-------------------|------| +| `not_found` | 404 | +| `validation_error`| 422 | +| `auth_error` | 401 | +| `forbidden` | 403 | +| `internal_error` | 500 | + +### SSE streaming + +``` +GET /kb/events?topics=proposals,approvals,lifecycle +Authorization: Bearer + +→ text/event-stream +event: proposal_created +data: {"proposal_id": "...", "actor": "ci-agent", ...} +``` + +### Auth modes + +| mode | behaviour | +|--------------|-----------| +| `none` | No auth; `vouch serve` refuses to start unless `--bind` is loopback. | +| `bearer` | Static token from `VOUCH_SERVER_TOKEN` env or `~/.vouch/server.token`. Compared with `hmac.compare_digest`. | +| `token-file` | Same as `bearer` but re-read on every request, enabling rotation without restart. | + +Per-token actor attribution via `.vouch/config.yaml`: + +```yaml +server: + tokens: + - name: ci-agent + token_hash: sha256: + - name: human-reviewer + token_hash: sha256: +``` + +`VOUCH_AGENT` env var on the server process is the fallback actor. + +### CLI proxy mode + +When `VOUCH_SERVER_URL` is set, every CLI command routes through the +HTTP server instead of the local filesystem: + +``` +VOUCH_SERVER_URL=http://kb.internal:7749 \ +VOUCH_SERVER_TOKEN= \ +vouch pending # fetches from remote +vouch approve # POSTs to remote +``` + +### Config model additions + +New `ServerConfig` and `TokenEntry` Pydantic models; new optional +`server:` key in `.vouch/config.yaml`. Old configs without it continue +to work (defaults apply). + +### Capabilities changes + +`kb.capabilities` gains two new fields: + +```json +{ + "transports": ["mcp", "jsonl", "http-jsonl", "mcp-http"], + "auth_modes": ["none", "bearer", "token-file"] +} +``` + +## Design + +### New files + +**`src/vouch/auth.py`** — `TokenStore`, `verify_token(token) → actor|None`, +`require_auth(request) → actor` (raises `AuthError` on failure). +Token hashes are `sha256:` stored in config; comparison uses +`hmac.compare_digest(sha256(candidate), stored_hash)`. + +**`src/vouch/http_server.py`** — Starlette `Router` that: +- Mounts `POST /kb/{method}` → `_dispatch(method, params, actor)` which + calls the same `HANDLERS` dict as `jsonl_server.py`. +- Mounts `GET /kb/events` → async SSE generator that tails + `audit.log.jsonl` and filters by `?topics=`. +- Auth middleware that extracts the `Authorization: Bearer` header, + calls `auth.require_auth`, and injects the actor into request state. +- Maps domain exceptions to HTTP status codes (see table above). + +### Shared dispatch + +`jsonl_server.py` already has `HANDLERS: dict[str, Callable[[dict], Any]]`. +`http_server.py` imports and reuses them directly — no duplication. + +### SSE delivery + +The SSE endpoint polls `audit.log.jsonl` with a short sleep (`asyncio.sleep(0.5)`) +and sends new lines as they appear. This is intentionally simple — no +message broker, no websocket upgrade. Topics filter by the `event` field +prefix (`proposals` → `proposal.*`, `approvals` → `proposal.approve`, +`lifecycle` → `claim.*`). + +### mcp-http + +`vouch serve --transport mcp-http` runs FastMCP's streamable-HTTP transport +if the MCP library supports it, falling back with a clear error if not. +This keeps the MCP tool surface intact for runtimes that speak MCP-over-HTTP. + +## Compatibility + +- Existing `stdio` and `jsonl` transports: **no change**. +- `vouch serve` with no flags: **no change** (still MCP stdio). +- `.vouch/config.yaml`: new optional `server:` key; old configs load fine. +- Bundle format: **unchanged**. +- `kb.capabilities` shape: additive (`transports`, `auth_modes` fields); + old adapters that don't read these fields are unaffected. +- New optional-dependency group `[http]` in `pyproject.toml`; base + install is unchanged. + +## Security implications + +**Loopback enforcement for `--auth none`:** The server refuses to bind to +a non-loopback address without an auth token. This prevents accidentally +exposing an unauthenticated KB to the network. + +**Timing-safe comparison:** `hmac.compare_digest` prevents timing oracle +attacks on token comparison. + +**Token hashes, not plaintext:** Config stores `sha256:` of each +token, not the token itself. An attacker who reads `config.yaml` cannot +recover the token. + +**Actor injection:** The `VOUCH_AGENT` header is NOT trusted from the +client. The server resolves actor from the token's configured `name`. This +prevents clients from forging attribution. + +**No new write paths outside the review gate:** HTTP handlers call the same +proposal machinery as the JSONL server. The review gate is unchanged. + +## Performance implications + +- SSE polling adds at most ~0.5 s latency per event; for team review + workflows this is acceptable. A future VEP can replace with inotify/ + kqueue for sub-100 ms latency. +- Starlette + uvicorn add ~20 MB RSS; acceptable for a persistent sidecar. +- JSONL dispatch overhead is negligible (same handlers as existing transport). + +## Open questions + +1. Should `token-file` support multiple files (one per agent) or a single + shared file? Current proposal: single file for simplicity; per-agent + files are config-level anyway (via the `tokens:` list in config.yaml). +2. TLS termination: current proposal says "reverse-proxy handles TLS". + Should we add `--tls-cert/--tls-key` as a first-class flag? Deferred + to a follow-up VEP to keep scope small. +3. Rate limiting and request body size cap: not in this VEP. The assumption + is the server is behind a trusted network or reverse proxy. A future VEP + can add middleware. + +## Alternatives considered + +- **`socat` over TCP:** No auth, no TLS, no streaming. Fragile. +- **Git-sync proposals:** Pollutes PR history; defeats review-gate semantics. +- **Dedicated webhook server:** Solves streaming but not remote-approve. +- **MCP-over-HTTP only:** Breaks existing JSONL adapters. + +## References + +- Issue: feat: HTTP transport with bearer-token auth and SSE streaming +- VEP-0002: JSONL transport (the transport this extends) +- MCP streamable-HTTP spec +- AKBP §7: transport negotiation diff --git a/pyproject.toml b/pyproject.toml index 3c358527..76dfee13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,10 @@ embeddings-fast = [ rerank = [ "sentence-transformers>=2.7,<4", ] +http = [ + "starlette>=0.40,<1", + "uvicorn[standard]>=0.30,<1", +] [project.scripts] vouch = "vouch.cli:cli" diff --git a/src/vouch/auth.py b/src/vouch/auth.py new file mode 100644 index 00000000..d62086fe --- /dev/null +++ b/src/vouch/auth.py @@ -0,0 +1,132 @@ +"""Bearer-token authentication and actor resolution for the HTTP transport. + +Three auth modes (VEP-0004): + none — no check; server refuses to start unless bind is loopback. + bearer — static token from VOUCH_SERVER_TOKEN env or ~/.vouch/server.token. + token-file — same as bearer but token is re-read on every request, + enabling hot rotation without a restart. + +Tokens are compared with hmac.compare_digest to avoid timing oracles. +Config stores sha256: hashes, not plaintext tokens. +""" + +from __future__ import annotations + +import hashlib +import hmac +import ipaddress +import os +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml + +if TYPE_CHECKING: + from .models import ServerConfig + + +class AuthError(Exception): + """Raised when a request fails authentication.""" + + +_DEFAULT_TOKEN_FILE = Path.home() / ".vouch" / "server.token" + + +def _sha256_hex(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest() + + +def _safe_eq(a: str, b: str) -> bool: + return hmac.compare_digest(a.encode(), b.encode()) + + +def _load_server_config(kb_dir: Path) -> ServerConfig | None: + cfg_path = kb_dir / "config.yaml" + if not cfg_path.exists(): + return None + raw = yaml.safe_load(cfg_path.read_text()) or {} + server_raw = raw.get("server") + if not server_raw: + return None + from .models import ServerConfig + return ServerConfig.model_validate(server_raw) + + +def resolve_actor(token: str, kb_dir: Path, fallback: str) -> str: + """Return the actor name for a validated token. + + Checks the config.yaml server.tokens list first; falls back to fallback + (which should be the VOUCH_AGENT env var or 'unknown-agent'). + """ + cfg = _load_server_config(kb_dir) + if cfg is None or not cfg.tokens: + return fallback + candidate_hash = _sha256_hex(token) + for entry in cfg.tokens: + stored = entry.token_hash + if stored.startswith("sha256:"): + stored = stored[len("sha256:"):] + if _safe_eq(candidate_hash, stored): + return entry.name + return fallback + + +def verify_token(token: str, mode: str, kb_dir: Path) -> bool: + """Return True if the token is valid for the given auth mode.""" + if mode == "none": + return True + raw_token = _read_static_token(mode) + if raw_token is None: + return False + return _safe_eq(_sha256_hex(token), _sha256_hex(raw_token)) + + +def _read_static_token(mode: str) -> str | None: + """Read the configured token from env or file.""" + env_token = os.environ.get("VOUCH_SERVER_TOKEN") + if env_token: + return env_token + if _DEFAULT_TOKEN_FILE.exists(): + return _DEFAULT_TOKEN_FILE.read_text().strip() + return None + + +def require_auth(authorization_header: str | None, mode: str, kb_dir: Path) -> str: + """Extract and validate the bearer token from an Authorization header. + + Returns the raw token string so the caller can do actor resolution. + Raises AuthError on any failure. + """ + if mode == "none": + return "" + if not authorization_header: + raise AuthError("missing Authorization header") + parts = authorization_header.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + raise AuthError("Authorization header must be 'Bearer '") + token = parts[1].strip() + if not token: + raise AuthError("empty bearer token") + if not verify_token(token, mode, kb_dir): + raise AuthError("invalid token") + return token + + +def assert_loopback_for_no_auth(bind: str) -> None: + """Raise if bind address is not loopback when auth=none.""" + host = bind.split(":")[0] + try: + addr = ipaddress.ip_address(host) + if not addr.is_loopback: + raise ValueError( + f"--auth none is only allowed when --bind is a loopback address " + f"(got {host!r}). Use --auth bearer or --auth token-file for " + f"non-loopback binds." + ) + except ValueError as e: + if "loopback" in str(e): + raise + if host not in ("localhost",): + raise ValueError( + f"--auth none with non-loopback host {host!r} is not allowed" + ) from e diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 9abcc685..c0f29133 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -69,10 +69,20 @@ def capabilities() -> Capabilities: retrieval.append("hybrid") except Exception: pass + + transports = ["mcp", "jsonl"] + try: + import starlette # noqa: F401 + import uvicorn # noqa: F401 + transports += ["http-jsonl", "mcp-http"] + except ImportError: + pass + return Capabilities( version=__version__, methods=METHODS, retrieval=retrieval, review_gated=True, - transports=["mcp", "jsonl"], + transports=transports, + auth_modes=["none", "bearer", "token-file"], ) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 100dcdce..e9aeb499 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -773,15 +773,42 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: @cli.command() @click.option("--transport", default="stdio", show_default=True, - type=click.Choice(["stdio", "jsonl"])) -def serve(transport: str) -> None: - """Run the MCP server (stdio) or the JSONL tool server.""" + type=click.Choice(["stdio", "jsonl", "http", "mcp-http"])) +@click.option("--bind", default="127.0.0.1:7749", show_default=True, + help="HOST:PORT for the HTTP transport (ignored for stdio/jsonl).") +@click.option("--auth", "auth_mode", default="token-file", show_default=True, + type=click.Choice(["bearer", "token-file", "none"]), + help="Auth mode for the HTTP transport.") +def serve(transport: str, bind: str, auth_mode: str) -> None: + """Run the MCP server (stdio), the JSONL tool server, or an HTTP server.""" if transport == "stdio": from .server import run_stdio run_stdio() - else: + elif transport == "jsonl": from .jsonl_server import run_jsonl run_jsonl() + elif transport == "http": + try: + from .http_server import run_http + except ImportError as e: + raise click.ClickException(str(e)) from e + run_http(bind=bind, auth_mode=auth_mode) + else: + try: + from starlette.applications import Starlette # noqa: F401 + except ImportError as e: + raise click.ClickException( + "mcp-http transport requires the 'http' optional dependencies: " + "pip install 'vouch-kb[http]'" + ) from e + try: + from .auth import assert_loopback_for_no_auth + if auth_mode == "none": + assert_loopback_for_no_auth(bind) + from .server import mcp + mcp.run(transport="streamable-http") + except Exception as e: + raise click.ClickException(str(e)) from e if __name__ == "__main__": diff --git a/src/vouch/http_server.py b/src/vouch/http_server.py new file mode 100644 index 00000000..827af1f5 --- /dev/null +++ b/src/vouch/http_server.py @@ -0,0 +1,240 @@ +"""HTTP/SSE transport for the vouch KB server (VEP-0004). + +Wire format: + + POST /kb/ + Content-Type: application/json + Authorization: Bearer + { ...params... } + → 200 { ...result... } | 4xx/5xx { "error": { "code": "...", "message": "..." } } + + GET /kb/events?topics=proposals,approvals,lifecycle + Authorization: Bearer + → text/event-stream + +Dispatch reuses the same HANDLERS dict as jsonl_server.py — no duplication. + +Auth modes: none | bearer | token-file (see auth.py). +Actor is resolved from the token's configured name in config.yaml, falling +back to the VOUCH_AGENT env var. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import traceback +from pathlib import Path +from typing import Any + +from .auth import AuthError, assert_loopback_for_no_auth, require_auth, resolve_actor +from .jsonl_server import HANDLERS +from .proposals import ProposalError +from .storage import ArtifactNotFoundError, KBNotFoundError, KBStore, discover_root + +try: + from starlette.applications import Starlette + from starlette.requests import Request + from starlette.responses import JSONResponse, Response + from starlette.routing import Route + _STARLETTE_AVAILABLE = True +except ImportError: + _STARLETTE_AVAILABLE = False + + +_ERROR_CODE_TO_STATUS: dict[str, int] = { + "not_found": 404, + "validation_error": 422, + "auth_error": 401, + "forbidden": 403, + "method_not_found": 404, + "missing_param": 422, + "invalid_request": 422, + "internal_error": 500, +} + + +def _error_response(code: str, message: str) -> JSONResponse: + status = _ERROR_CODE_TO_STATUS.get(code, 500) + return JSONResponse({"error": {"code": code, "message": message}}, status_code=status) + + +def _get_store() -> KBStore: + try: + return KBStore(discover_root()) + except KBNotFoundError as e: + raise RuntimeError(str(e)) from e + + +def _fallback_actor() -> str: + return os.environ.get("VOUCH_AGENT", "unknown-agent") + + +def _make_dispatch_handler(auth_mode: str) -> Any: + async def dispatch(request: Request) -> Response: + method_name = request.path_params["method"] + full_method = f"kb.{method_name}" + + kb_dir: Path + try: + store = _get_store() + kb_dir = store.kb_dir + except RuntimeError as e: + return _error_response("internal_error", str(e)) + + auth_header = request.headers.get("authorization") + try: + token = require_auth(auth_header, auth_mode, kb_dir) + except AuthError as e: + return _error_response("auth_error", str(e)) + + actor = resolve_actor(token, kb_dir, _fallback_actor()) if token else _fallback_actor() + + if full_method not in HANDLERS: + return _error_response("method_not_found", f"unknown method: {full_method!r}") + + try: + body = await request.body() + params: dict[str, Any] = json.loads(body) if body else {} + except json.JSONDecodeError as e: + return _error_response("validation_error", f"invalid JSON body: {e}") + + saved_agent = os.environ.get("VOUCH_AGENT") + os.environ["VOUCH_AGENT"] = actor + try: + result = HANDLERS[full_method](params) + except KeyError as e: + return _error_response("missing_param", str(e)) + except (ValueError, ProposalError, ArtifactNotFoundError) as e: + return _error_response("invalid_request", str(e)) + except Exception as e: + return _error_response("internal_error", f"{e}\n{traceback.format_exc()}") + finally: + if saved_agent is None: + os.environ.pop("VOUCH_AGENT", None) + else: + os.environ["VOUCH_AGENT"] = saved_agent + + return JSONResponse(result) + + return dispatch + + +_TOPIC_PREFIXES: dict[str, tuple[str, ...]] = { + "proposals": ("proposal.",), + "approvals": ("proposal.approve",), + "lifecycle": ("claim.", "page.", "entity.", "relation."), + "all": (), +} + + +def _event_matches(event_name: str, topics: list[str]) -> bool: + if not topics or "all" in topics: + return True + for topic in topics: + prefixes = _TOPIC_PREFIXES.get(topic, (topic,)) + for prefix in prefixes: + if event_name.startswith(prefix): + return True + return False + + +def _make_sse_handler(auth_mode: str) -> Any: + async def sse_events(request: Request) -> Response: + try: + store = _get_store() + kb_dir = store.kb_dir + except RuntimeError as e: + return _error_response("internal_error", str(e)) + + auth_header = request.headers.get("authorization") + try: + require_auth(auth_header, auth_mode, kb_dir) + except AuthError as e: + return _error_response("auth_error", str(e)) + + topics_param = request.query_params.get("topics", "all") + topics = [t.strip() for t in topics_param.split(",") if t.strip()] + + audit_log = kb_dir / "audit.log.jsonl" + + async def generate() -> Any: + yield b"retry: 1000\n\n" + pos = audit_log.stat().st_size if audit_log.exists() else 0 + while True: + await asyncio.sleep(0.5) + if not audit_log.exists(): + continue + with audit_log.open("rb") as fh: + fh.seek(pos) + chunk = fh.read() + pos_delta = fh.tell() - pos + if pos_delta == 0: + continue + pos += pos_delta + for raw_line in chunk.splitlines(): + raw_line = raw_line.strip() + if not raw_line: + continue + try: + record = json.loads(raw_line) + except json.JSONDecodeError: + continue + event_name = record.get("event", "") + if not _event_matches(event_name, topics): + continue + data = json.dumps(record, default=str) + yield f"event: {event_name}\ndata: {data}\n\n".encode() + + return Response( + content=generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + return sse_events + + +def build_app(auth_mode: str = "token-file") -> Starlette: + """Build and return the Starlette ASGI app.""" + if not _STARLETTE_AVAILABLE: + raise ImportError( + "HTTP transport requires the 'http' optional dependencies: " + "pip install 'vouch-kb[http]'" + ) + + routes = [ + Route("/kb/events", endpoint=_make_sse_handler(auth_mode), methods=["GET"]), + Route("/kb/{method:path}", endpoint=_make_dispatch_handler(auth_mode), methods=["POST"]), + ] + return Starlette(routes=routes) + + +def run_http(bind: str = "127.0.0.1:7749", auth_mode: str = "token-file") -> None: + """Start the HTTP server. Called by `vouch serve --transport http`.""" + if not _STARLETTE_AVAILABLE: + raise ImportError( + "HTTP transport requires the 'http' optional dependencies: " + "pip install 'vouch-kb[http]'" + ) + + try: + import uvicorn + except ImportError as e: + raise ImportError( + "HTTP transport requires uvicorn: pip install 'vouch-kb[http]'" + ) from e + + if auth_mode == "none": + assert_loopback_for_no_auth(bind) + + host, _, port_str = bind.rpartition(":") + host = host or "127.0.0.1" + port = int(port_str) if port_str else 7749 + + app = build_app(auth_mode=auth_mode) + uvicorn.run(app, host=host, port=port) diff --git a/src/vouch/models.py b/src/vouch/models.py index a4d1961b..c764e2ca 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -374,3 +374,23 @@ class Capabilities(BaseModel): "audit_log": True, } ) + auth_modes: list[str] = Field(default_factory=list) + + +# --- server config (VEP-0004) ---------------------------------------------- + + +class TokenEntry(BaseModel): + """One named bearer token stored as a sha256 hash.""" + + name: str + token_hash: str = Field(description="'sha256:' — hash stored, not plaintext") + + +class ServerConfig(BaseModel): + """Optional server: stanza in .vouch/config.yaml (VEP-0004).""" + + bind: str = "127.0.0.1:7749" + auth: str = "token-file" + tls: bool = False + tokens: list[TokenEntry] = Field(default_factory=list) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 00000000..7e0a8c96 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,140 @@ +"""Auth layer — token validation, timing-safe compare, loopback enforcement.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest +import yaml + +from vouch.auth import ( + AuthError, + assert_loopback_for_no_auth, + require_auth, + resolve_actor, + verify_token, +) +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _write_token_config(store: KBStore, token: str, name: str) -> None: + token_hash = "sha256:" + hashlib.sha256(token.encode()).hexdigest() + cfg_path = store.kb_dir / "config.yaml" + cfg = yaml.safe_load(cfg_path.read_text()) or {} + cfg.setdefault("server", {}).setdefault("tokens", []).append( + {"name": name, "token_hash": token_hash} + ) + cfg_path.write_text(yaml.safe_dump(cfg)) + + +# --- verify_token ----------------------------------------------------------- + + +def test_verify_token_none_mode_always_passes(store: KBStore, monkeypatch) -> None: + monkeypatch.delenv("VOUCH_SERVER_TOKEN", raising=False) + assert verify_token("anything", "none", store.kb_dir) + + +def test_verify_token_bearer_correct(store: KBStore, monkeypatch) -> None: + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "secret123") + assert verify_token("secret123", "bearer", store.kb_dir) + + +def test_verify_token_bearer_wrong(store: KBStore, monkeypatch) -> None: + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "secret123") + assert not verify_token("wrongtoken", "bearer", store.kb_dir) + + +def test_verify_token_token_file_reads_file(store: KBStore, monkeypatch, tmp_path) -> None: + token_file = tmp_path / "server.token" + token_file.write_text("filetoken\n") + monkeypatch.delenv("VOUCH_SERVER_TOKEN", raising=False) + monkeypatch.setattr("vouch.auth._DEFAULT_TOKEN_FILE", token_file) + assert verify_token("filetoken", "token-file", store.kb_dir) + + +def test_verify_token_missing_token_returns_false(store: KBStore, monkeypatch) -> None: + monkeypatch.delenv("VOUCH_SERVER_TOKEN", raising=False) + monkeypatch.setattr("vouch.auth._DEFAULT_TOKEN_FILE", Path("/nonexistent/token")) + assert not verify_token("anything", "bearer", store.kb_dir) + + +# --- require_auth ----------------------------------------------------------- + + +def test_require_auth_none_mode_no_header(store: KBStore) -> None: + token = require_auth(None, "none", store.kb_dir) + assert token == "" + + +def test_require_auth_bearer_valid(store: KBStore, monkeypatch) -> None: + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "mytoken") + token = require_auth("Bearer mytoken", "bearer", store.kb_dir) + assert token == "mytoken" + + +def test_require_auth_missing_header_raises(store: KBStore, monkeypatch) -> None: + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "mytoken") + with pytest.raises(AuthError, match="missing Authorization"): + require_auth(None, "bearer", store.kb_dir) + + +def test_require_auth_wrong_scheme_raises(store: KBStore, monkeypatch) -> None: + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "mytoken") + with pytest.raises(AuthError, match="Bearer"): + require_auth("Basic dXNlcjpwYXNz", "bearer", store.kb_dir) + + +def test_require_auth_invalid_token_raises(store: KBStore, monkeypatch) -> None: + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "mytoken") + with pytest.raises(AuthError, match="invalid token"): + require_auth("Bearer wrongtoken", "bearer", store.kb_dir) + + +# --- resolve_actor ---------------------------------------------------------- + + +def test_resolve_actor_from_config(store: KBStore, monkeypatch) -> None: + _write_token_config(store, "agenttoken", "ci-agent") + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "agenttoken") + actor = resolve_actor("agenttoken", store.kb_dir, "fallback-actor") + assert actor == "ci-agent" + + +def test_resolve_actor_falls_back_when_no_match(store: KBStore, monkeypatch) -> None: + _write_token_config(store, "othertoken", "other-agent") + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "mytoken") + actor = resolve_actor("mytoken", store.kb_dir, "fallback-actor") + assert actor == "fallback-actor" + + +def test_resolve_actor_no_config(store: KBStore) -> None: + actor = resolve_actor("anytoken", store.kb_dir, "default-agent") + assert actor == "default-agent" + + +# --- loopback enforcement --------------------------------------------------- + + +def test_loopback_ok_for_127() -> None: + assert_loopback_for_no_auth("127.0.0.1:7749") + + +def test_loopback_ok_for_localhost() -> None: + assert_loopback_for_no_auth("localhost:7749") + + +def test_loopback_rejects_0000() -> None: + with pytest.raises(ValueError, match="loopback"): + assert_loopback_for_no_auth("0.0.0.0:7749") + + +def test_loopback_rejects_external_ip() -> None: + with pytest.raises(ValueError, match="loopback"): + assert_loopback_for_no_auth("10.0.0.1:7749") diff --git a/tests/test_http_server.py b/tests/test_http_server.py new file mode 100644 index 00000000..6dccc51a --- /dev/null +++ b/tests/test_http_server.py @@ -0,0 +1,181 @@ +"""HTTP server — route dispatch, auth rejection, error mapping.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest +import yaml + +starlette = pytest.importorskip("starlette", reason="starlette not installed; skipping HTTP tests") + +from starlette.testclient import TestClient # noqa: E402 + +from vouch.http_server import build_app # noqa: E402 +from vouch.storage import KBStore # noqa: E402 + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +@pytest.fixture +def client_none_auth(store: KBStore, monkeypatch) -> TestClient: + monkeypatch.chdir(store.root) + app = build_app(auth_mode="none") + return TestClient(app, raise_server_exceptions=False) + + +@pytest.fixture +def client_bearer(store: KBStore, monkeypatch) -> TestClient: + monkeypatch.chdir(store.root) + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "testtoken") + app = build_app(auth_mode="bearer") + return TestClient(app, raise_server_exceptions=False) + + +# --- auth rejection --------------------------------------------------------- + + +def test_missing_auth_returns_401(client_bearer: TestClient) -> None: + resp = client_bearer.post("/kb/status", content="{}") + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == "auth_error" + + +def test_wrong_token_returns_401(client_bearer: TestClient) -> None: + resp = client_bearer.post( + "/kb/status", content="{}", + headers={"Authorization": "Bearer wrongtoken"}, + ) + assert resp.status_code == 401 + + +def test_valid_token_accepted(client_bearer: TestClient) -> None: + resp = client_bearer.post( + "/kb/status", content="{}", + headers={"Authorization": "Bearer testtoken"}, + ) + assert resp.status_code == 200 + + +def test_none_auth_no_header_accepted(client_none_auth: TestClient) -> None: + resp = client_none_auth.post("/kb/status", content="{}") + assert resp.status_code == 200 + + +# --- route dispatch --------------------------------------------------------- + + +def test_status_returns_counts(client_none_auth: TestClient) -> None: + resp = client_none_auth.post("/kb/status", content="{}") + assert resp.status_code == 200 + data = resp.json() + assert "claims" in data + assert "pending_proposals" in data + + +def test_capabilities_response(client_none_auth: TestClient) -> None: + resp = client_none_auth.post("/kb/capabilities", content="{}") + assert resp.status_code == 200 + data = resp.json() + assert data["review_gated"] is True + assert "kb.status" in data["methods"] + + +def test_unknown_method_returns_404(client_none_auth: TestClient) -> None: + resp = client_none_auth.post("/kb/bogus_method", content="{}") + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == "method_not_found" + + +def test_invalid_json_body_returns_422(client_none_auth: TestClient) -> None: + resp = client_none_auth.post( + "/kb/status", + content="not-json", + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 422 + + +def test_propose_and_list_pending(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.root) + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "tok") + app = build_app(auth_mode="bearer") + client = TestClient(app, raise_server_exceptions=False) + headers = {"Authorization": "Bearer tok"} + + src = store.put_source(b"test evidence") + body = json.dumps({"text": "HTTP claim", "evidence": [src.id]}) + resp = client.post("/kb/propose_claim", content=body, headers=headers) + assert resp.status_code == 200 + assert "proposal_id" in resp.json() + + resp2 = client.post("/kb/list_pending", content="{}", headers=headers) + assert resp2.status_code == 200 + assert len(resp2.json()) == 1 + + +def test_approve_flow(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.root) + monkeypatch.setenv("VOUCH_SERVER_TOKEN", "tok") + + # Configure trusted-agent so self-approval is allowed in this test + cfg_path = store.kb_dir / "config.yaml" + cfg = yaml.safe_load(cfg_path.read_text()) or {} + cfg.setdefault("review", {})["approver_role"] = "trusted-agent" + cfg_path.write_text(yaml.safe_dump(cfg)) + + app = build_app(auth_mode="bearer") + client = TestClient(app, raise_server_exceptions=False) + headers = {"Authorization": "Bearer tok"} + + src = store.put_source(b"evidence") + propose_body = json.dumps({"text": "approved claim", "evidence": [src.id]}) + pr_resp = client.post("/kb/propose_claim", content=propose_body, headers=headers) + assert pr_resp.status_code == 200 + pid = pr_resp.json()["proposal_id"] + + approve_body = json.dumps({"proposal_id": pid}) + ap_resp = client.post("/kb/approve", content=approve_body, headers=headers) + assert ap_resp.status_code == 200 + assert "id" in ap_resp.json() + + status_resp = client.post("/kb/status", content="{}", headers=headers) + assert status_resp.json()["claims"] == 1 + + +def test_actor_resolved_from_token_config(store: KBStore, monkeypatch) -> None: + """Actor written to the audit log should come from the token config name.""" + token = "agenttoken" + token_hash = "sha256:" + hashlib.sha256(token.encode()).hexdigest() + cfg_path = store.kb_dir / "config.yaml" + cfg = yaml.safe_load(cfg_path.read_text()) or {} + cfg.setdefault("server", {}).setdefault("tokens", []).append( + {"name": "ci-agent", "token_hash": token_hash} + ) + cfg.setdefault("review", {})["approver_role"] = "trusted-agent" + cfg_path.write_text(yaml.safe_dump(cfg)) + + monkeypatch.chdir(store.root) + monkeypatch.setenv("VOUCH_SERVER_TOKEN", token) + app = build_app(auth_mode="bearer") + client = TestClient(app, raise_server_exceptions=False) + headers = {"Authorization": f"Bearer {token}"} + + src = store.put_source(b"ev") + resp = client.post( + "/kb/propose_claim", + content=json.dumps({"text": "actor test", "evidence": [src.id]}), + headers=headers, + ) + assert resp.status_code == 200 + + from vouch import audit + events = list(audit.read_events(store.kb_dir)) + proposal_events = [e for e in events if "proposal" in e.event] + assert proposal_events + assert proposal_events[0].actor == "ci-agent" From ba012648a15b98ce6f6f61351d1be3a921389b8d Mon Sep 17 00:00:00 2001 From: greatjourney589 Date: Wed, 3 Jun 2026 14:31:28 -0400 Subject: [PATCH 2/2] fix(auth,http,serve): fix per-token auth, SSE streaming, traceback leak, and mcp-http bind --- src/vouch/auth.py | 21 +++++++++++++++++++-- src/vouch/capabilities.py | 2 +- src/vouch/cli.py | 12 +++++++++++- src/vouch/http_server.py | 15 +++++++++------ 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/vouch/auth.py b/src/vouch/auth.py index d62086fe..95e07a87 100644 --- a/src/vouch/auth.py +++ b/src/vouch/auth.py @@ -75,14 +75,31 @@ def verify_token(token: str, mode: str, kb_dir: Path) -> bool: """Return True if the token is valid for the given auth mode.""" if mode == "none": return True + candidate_hash = _sha256_hex(token) + cfg = _load_server_config(kb_dir) + if cfg and cfg.tokens: + for entry in cfg.tokens: + stored = entry.token_hash + if stored.startswith("sha256:"): + stored = stored[len("sha256:"):] + if _safe_eq(candidate_hash, stored): + return True raw_token = _read_static_token(mode) if raw_token is None: return False - return _safe_eq(_sha256_hex(token), _sha256_hex(raw_token)) + return _safe_eq(candidate_hash, _sha256_hex(raw_token)) def _read_static_token(mode: str) -> str | None: - """Read the configured token from env or file.""" + """Read the configured token from env or file. + + bearer: env var first, then token file. + token-file: token file only (re-read on every call, enabling hot rotation). + """ + if mode == "token-file": + if _DEFAULT_TOKEN_FILE.exists(): + return _DEFAULT_TOKEN_FILE.read_text().strip() + return None env_token = os.environ.get("VOUCH_SERVER_TOKEN") if env_token: return env_token diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index c0f29133..d5cec2e1 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -74,7 +74,7 @@ def capabilities() -> Capabilities: try: import starlette # noqa: F401 import uvicorn # noqa: F401 - transports += ["http-jsonl", "mcp-http"] + transports += ["http", "mcp-http"] except ImportError: pass diff --git a/src/vouch/cli.py b/src/vouch/cli.py index e9aeb499..b5c88853 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -805,8 +805,18 @@ def serve(transport: str, bind: str, auth_mode: str) -> None: from .auth import assert_loopback_for_no_auth if auth_mode == "none": assert_loopback_for_no_auth(bind) + elif auth_mode in ("bearer", "token-file"): + raise click.ClickException( + f"--auth {auth_mode} is not supported for --transport mcp-http; " + "use --transport http for bearer-token auth." + ) + host, _, port_str = bind.rpartition(":") + mcp_host = host or "127.0.0.1" + mcp_port = int(port_str) if port_str else 7749 from .server import mcp - mcp.run(transport="streamable-http") + mcp.run(transport="streamable-http", host=mcp_host, port=mcp_port) + except click.ClickException: + raise except Exception as e: raise click.ClickException(str(e)) from e diff --git a/src/vouch/http_server.py b/src/vouch/http_server.py index 827af1f5..5fb69767 100644 --- a/src/vouch/http_server.py +++ b/src/vouch/http_server.py @@ -23,11 +23,13 @@ import asyncio import json +import logging import os -import traceback from pathlib import Path from typing import Any +logger = logging.getLogger(__name__) + from .auth import AuthError, assert_loopback_for_no_auth, require_auth, resolve_actor from .jsonl_server import HANDLERS from .proposals import ProposalError @@ -36,7 +38,7 @@ try: from starlette.applications import Starlette from starlette.requests import Request - from starlette.responses import JSONResponse, Response + from starlette.responses import JSONResponse, Response, StreamingResponse from starlette.routing import Route _STARLETTE_AVAILABLE = True except ImportError: @@ -108,8 +110,9 @@ async def dispatch(request: Request) -> Response: return _error_response("missing_param", str(e)) except (ValueError, ProposalError, ArtifactNotFoundError) as e: return _error_response("invalid_request", str(e)) - except Exception as e: - return _error_response("internal_error", f"{e}\n{traceback.format_exc()}") + except Exception: + logger.exception("handler %s failed", full_method) + return _error_response("internal_error", "internal server error") finally: if saved_agent is None: os.environ.pop("VOUCH_AGENT", None) @@ -187,8 +190,8 @@ async def generate() -> Any: data = json.dumps(record, default=str) yield f"event: {event_name}\ndata: {data}\n\n".encode() - return Response( - content=generate(), + return StreamingResponse( + generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache",