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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ All notable changes to vouch are documented here. Format follows
from the cited claims' lifecycle status. Deterministic in v1 (no LLM in the
loop). Exposed across the CLI (`vouch synthesize`), MCP (`kb_synthesize`),
and JSONL (`kb.synthesize`) surfaces (#222).
- `_meta.vouch_trust` on every dict-shaped kb.* response: `{remote, caller_kind,
auth_subject}` so clients can detect remote confinement and surface it in
their UI. HTTP MCP calls report `remote: true, caller_kind: mcp_http`; CLI
`--json` reports `remote: false, caller_kind: cli`. Bearer-authenticated
HTTP calls include a stable token fingerprint as `auth_subject` (#233).
- Entity-salience retrieval reflex: a per-session, in-memory ring buffer of
recent caller queries drives a zero-LLM substring/FTS entity pass that
attaches top-K matched claim candidates as `_meta.vouch_salience` on
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,36 @@ The JSONL transport reads one envelope per line on stdin, writes one per line on

Errors come back with `ok:false` and a structured `error.code` (`method_not_found`, `missing_param`, `invalid_request`, `internal_error`).

Every successful `kb.*` result that is object-shaped carries read-only trust metadata so clients can detect remote confinement:

```json
{
"id": "r1",
"ok": true,
"result": {
"backend": "fts5",
"hits": [],
"_meta": {
"vouch_trust": {
"remote": false,
"caller_kind": "jsonl",
"auth_subject": null
}
}
}
}
```

| Transport | `remote` | `caller_kind` | `auth_subject` |
|-----------|----------|---------------|----------------|
| JSONL stdio | `false` | `jsonl` | `null` |
| HTTP `/rpc` | `true` | `jsonl_http` | bearer fingerprint when authenticated |
| MCP stdio | `false` | `mcp_stdio` | `null` |
| HTTP `/mcp` | `true` | `mcp_http` | bearer fingerprint when authenticated |
| CLI `--json` | `false` | `cli` | `null` |

The block is server-attached metadata — client mutations are ignored. Array-shaped read results (e.g. `kb.list_claims`) pass through unchanged; trust rides on dict-shaped responses only (#233).

## Portable bundles

```bash
Expand Down
4 changes: 4 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from . import stats as stats_mod
from . import sync as sync_mod
from . import synthesize as synth
from . import trust as trust_mod
from . import vault_sync as vault_sync_mod
from . import verify as verify_mod
from .capabilities import capabilities as build_caps
Expand Down Expand Up @@ -103,6 +104,9 @@ def _whoami() -> str:


def _emit_json(obj) -> None:
with trust_mod.trust_context(trust_mod.CLI):
if isinstance(obj, dict):
obj = trust_mod.attach_trust(obj)
Comment thread
claytonlin1110 marked this conversation as resolved.
click.echo(json.dumps(obj, indent=2, default=str, sort_keys=True))


Expand Down
47 changes: 42 additions & 5 deletions src/vouch/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from collections.abc import AsyncIterator, Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, cast

import uvicorn
import yaml
Expand All @@ -60,9 +60,10 @@
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.types import ASGIApp
from starlette.types import ASGIApp, Receive, Send

from . import jsonl_server
from . import trust as trust_mod
from .capabilities import capabilities as build_caps

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -201,8 +202,14 @@ async def _rpc(request: Request) -> JSONResponse:

agent = request.headers.get("X-Vouch-Agent")
reset = jsonl_server._actor.set(agent) if agent else None
bearer = trust_mod.matched_bearer_token(
request.headers.get("authorization"),
tuple(getattr(request.app.state, "vouch_bearer_tokens", ()) or ()),
)
trust = trust_mod.with_auth_subject(trust_mod.JSONL_HTTP, bearer)
try:
response = jsonl_server.handle_request(envelope)
with trust_mod.trust_context(trust):
response = jsonl_server.handle_request(envelope)
finally:
if reset is not None:
jsonl_server._actor.reset(reset)
Expand Down Expand Up @@ -248,6 +255,33 @@ async def dispatch(self, request: Request, call_next): # type: ignore[override]
return await call_next(request)


class _McpTrustASGI:
"""ASGI wrapper that sets ``vouch_trust`` for the full MCP request lifetime."""

def __init__(self, app: ASGIApp, *, accepted: tuple[str, ...]) -> None:
self._app = app
self._accepted = accepted

async def __call__(self, scope: dict, receive: Receive, send: Send) -> None:
if scope.get("type") != "http":
await self._app(scope, receive, send)
return
headers = {
k.decode("latin-1").lower(): v.decode("latin-1")
for k, v in scope.get("headers", [])
}
bearer = trust_mod.matched_bearer_token(
headers.get("authorization"),
self._accepted,
)
trust = trust_mod.with_auth_subject(trust_mod.MCP_HTTP, bearer)
token = trust_mod.set_trust_context(trust)
try:
await self._app(scope, receive, send)
finally:
trust_mod.reset_trust_context(token)


# --- ASGI app builder -----------------------------------------------------


Expand Down Expand Up @@ -311,7 +345,8 @@ def make_app(
security_settings=vouch_server.mcp.settings.transport_security,
retry_interval=getattr(vouch_server.mcp, "_retry_interval", None),
)
mcp_asgi: ASGIApp = StreamableHTTPASGIApp(session_manager)
mcp_inner = StreamableHTTPASGIApp(session_manager)
mcp_asgi = cast(ASGIApp, _McpTrustASGI(mcp_inner, accepted=tuple(accepted)))

routes: list = [
Route("/healthz", _healthz, methods=["GET"]),
Expand All @@ -329,11 +364,13 @@ async def _lifespan(_app: Starlette) -> AsyncIterator[None]:
async with session_manager.run():
yield

return Starlette(
app = Starlette(
routes=routes,
middleware=[Middleware(BearerMiddleware, accepted=accepted)],
lifespan=_lifespan,
)
app.state.vouch_bearer_tokens = tuple(accepted)
return app


# --- uvicorn wrapper that quacks like a stdlib HTTP server ----------------
Expand Down
8 changes: 7 additions & 1 deletion src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from . import lifecycle as life
from . import salience as salience_mod
from . import sessions as sess_mod
from . import trust as trust_mod
from . import verify as verify_mod
from .capabilities import capabilities as build_caps
from .context import build_context_pack
Expand Down Expand Up @@ -677,7 +678,11 @@ def handle_request(envelope: dict) -> dict:
}
try:
result = HANDLERS[method](params)
return {"id": req_id, "ok": True, "result": result}
return {
"id": req_id,
"ok": True,
"result": trust_mod.finish_kb_result(result),
}
except KeyError as e:
return {
"id": req_id, "ok": False,
Expand All @@ -702,6 +707,7 @@ def handle_request(envelope: dict) -> dict:
def run_jsonl(stdin=None, stdout=None) -> None:
"""Read one request per line, write one response per line."""
configure_logging()
trust_mod.set_stdio_default(trust_mod.JSONL_STDIO)
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
for line in stdin:
Expand Down
5 changes: 5 additions & 0 deletions src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from . import lifecycle as life
from . import salience as salience_mod
from . import sessions as sess_mod
from . import trust as trust_mod
from . import verify as verify_mod
from .capabilities import capabilities as build_caps
from .context import build_context_pack
Expand Down Expand Up @@ -827,7 +828,11 @@ def _current_model_name() -> str:
return ""


trust_mod.install_mcp_trust_wrappers(mcp)


def run_stdio() -> None:
"""Entry point used by `vouch serve`."""
configure_logging()
trust_mod.set_stdio_default(trust_mod.MCP_STDIO)
mcp.run()
Loading
Loading