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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ All notable changes to vouch are documented here. Format follows
relation proposals automatically, tagged `proposed_by: vouch-extractor`.
They land in `proposed/` like any hand-filed relation and need the usual
review; `vouch reject-extracted [--page <id>]` mass-rejects them (#224).
### Added
- Visibility-aware `kb.audit` / `vouch audit`: audit reads accept optional
`project` / `agent` viewer context (or nested `viewer_scope` on JSONL).
Events whose `object_ids` reference scoped claims, sources, or claim
proposals outside the viewer context are filtered out; events with no
`object_ids` remain visible to everyone (#232).
- `vouch sync --vault <dir>` — bidirectional sync between the KB and an
Obsidian/Logseq-style markdown vault. Forward (vault → KB): edits to
`<vault>/vouch/pages/<id>.md` become page-edit proposals citing a
Expand Down
4 changes: 3 additions & 1 deletion schemas/relation.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"similar_to",
"blocks",
"implements",
"references"
"references",
"mentions",
"relates_to"
],
"title": "RelationType",
"type": "string"
Expand Down
5 changes: 3 additions & 2 deletions spec/2026-05-21/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,9 @@ flags promotable claims for review.

### `kb.audit`

**Params:** `{ "tail": int?, "filter": {"event": str?, "actor": str?}? }`.
**Result:** array of `AuditEvent`.
**Params:** `{ "tail": int?, "project": str?, "agent": str?, "viewer_scope": {"project": str?, "agent": str?}? }`.
**Result:** `{ "viewer": {"project": str?, "agent": str?}, "events": [AuditEvent] }`.
Events whose `object_ids` reference scoped artifacts outside the viewer context are omitted; events with no `object_ids` (e.g. `kb.init`) are always included.

### `kb.export`

Expand Down
5 changes: 3 additions & 2 deletions spec/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,9 @@ flags promotable claims for review.

### `kb.audit`

**Params:** `{ "tail": int?, "filter": {"event": str?, "actor": str?}? }`.
**Result:** array of `AuditEvent`.
**Params:** `{ "tail": int?, "project": str?, "agent": str?, "viewer_scope": {"project": str?, "agent": str?}? }`.
**Result:** `{ "viewer": {"project": str?, "agent": str?}, "events": [AuditEvent] }`.
Events whose `object_ids` reference scoped artifacts outside the viewer context are omitted; events with no `object_ids` (e.g. `kb.init`) are always included.

### `kb.export`

Expand Down
31 changes: 27 additions & 4 deletions src/vouch/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@
import uuid
from collections.abc import Iterator
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any

from .models import AuditEvent

if TYPE_CHECKING:
from .scoping import ViewerContext
from .storage import KBStore

AUDIT_FILENAME = "audit.log.jsonl"


Expand Down Expand Up @@ -59,20 +63,39 @@ def log_event(
return ev


def read_events(kb_dir: Path) -> Iterator[AuditEvent]:
"""Stream every event in order. Safely skips malformed lines."""
def read_events(
kb_dir: Path,
*,
store: KBStore | None = None,
viewer: ViewerContext | None = None,
) -> Iterator[AuditEvent]:
"""Stream events in order. Safely skips malformed lines.

When *viewer* is set, *store* must also be provided so scoped
``object_ids`` can be resolved. Events referencing artifacts outside
the viewer context are omitted. Events with empty ``object_ids`` are
always included.
"""
if viewer is not None and store is None:
raise ValueError("read_events with viewer requires store for scope resolution")
path = _audit_path(kb_dir)
if not path.exists():
return
scoped = viewer is not None and store is not None
if scoped:
from .scoping import event_visible_to_viewer
with path.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
yield AuditEvent.model_validate(json.loads(line))
event = AuditEvent.model_validate(json.loads(line))
except (json.JSONDecodeError, ValueError):
continue
if scoped and not event_visible_to_viewer(store, event, viewer): # type: ignore[arg-type]
continue
yield event


def count_events(kb_dir: Path) -> int:
Expand Down
23 changes: 20 additions & 3 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1644,13 +1644,30 @@ def reindex(embeddings: bool, backfill: bool, force: bool, model: str | None) ->
@cli.command()
@click.option("--tail", default=20, show_default=True, type=int)
@click.option("--json", "as_json", is_flag=True)
def audit(tail: int, as_json: bool) -> None:
@click.option("--project", default=None, help="Viewer project for audit scope filtering.")
@click.option("--agent", default=None, help="Viewer agent for audit scope filtering.")
def audit(tail: int, as_json: bool, project: str | None, agent: str | None) -> None:
"""Read the audit log."""
from .scoping import viewer_from

store = _load_store()
events = list(audit_mod.read_events(store.kb_dir))[-tail:]
viewer = viewer_from(
config_path=store.config_path,
project=project,
agent=agent,
)
events = list(audit_mod.read_events(store.kb_dir, store=store, viewer=viewer))[-tail:]
if as_json:
_emit_json([e.model_dump(mode="json") for e in events])
_emit_json({
"viewer": {"project": viewer.project, "agent": viewer.agent},
"events": [e.model_dump(mode="json") for e in events],
})
return
if viewer.project or viewer.agent:
click.echo(
f"viewer: project={viewer.project!r} agent={viewer.agent!r}",
err=True,
)
for e in events:
click.echo(
f"{e.created_at.isoformat()} {e.event:30s} by {e.actor} objects={e.object_ids}"
Expand Down
16 changes: 11 additions & 5 deletions src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,11 +518,17 @@ def _h_import_apply(p: dict) -> dict:
return r


def _h_audit(p: dict) -> list[dict]:
return [
e.model_dump(mode="json")
for e in list(audit.read_events(_store().kb_dir))[-int(p.get("tail", 50)):]
]
def _h_audit(p: dict) -> dict:
from .scoping import viewer_from_params

s = _store()
viewer = viewer_from_params(s, p)
tail = int(p.get("tail", 50))
events = list(audit.read_events(s.kb_dir, store=s, viewer=viewer))[-tail:]
return {
"viewer": {"project": viewer.project, "agent": viewer.agent},
"events": [e.model_dump(mode="json") for e in events],
}


def _h_reindex_embeddings(p: dict) -> dict:
Expand Down
1 change: 1 addition & 0 deletions src/vouch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ class Capabilities(BaseModel):
default_factory=lambda: {
"enabled": True,
"viewer_params": ["project", "agent"],
"scoped_methods": ["kb.search", "kb.context_pack", "kb.audit"],
Comment thread
claytonlin1110 marked this conversation as resolved.
"env_vars": ["VOUCH_PROJECT", "VOUCH_AGENT"],
"config_path": "retrieval.scope",
}
Expand Down
102 changes: 96 additions & 6 deletions src/vouch/scoping.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
"""Viewer-context scoping for retrieval (VEP-0005).
"""Viewer-context scoping for retrieval and audit reads (VEP-0005).

Scope is a retrieval/relevance filter, not an access-control boundary.
Artifacts remain readable as plaintext YAML on disk regardless of scope.
Retrieval surfaces treat scope as a relevance filter. The audit read path
also applies scope so multi-project KBs do not leak events across project
boundaries in ``kb.audit`` / ``vouch audit``. Artifacts remain readable as
plaintext YAML on disk regardless of scope.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

import yaml

from .models import ArtifactScope, Visibility
from .models import ArtifactScope, ProposalKind, Visibility

if TYPE_CHECKING:
from .models import AuditEvent
from .storage import KBStore

_SCOPED_KINDS = frozenset({"claim", "source"})


@dataclass(frozen=True)
class ViewerContext:
"""Who is asking — used to filter retrieval hits."""
"""Who is asking — used to filter retrieval hits and audit timelines."""

project: str | None = None
agent: str | None = None


# Transport auth layers may pass a structured viewer scope (issue #232).
ScopeSpec = ViewerContext


def _norm(value: str | None) -> str | None:
if value is None:
return None
Expand Down Expand Up @@ -77,6 +84,22 @@ def viewer_from(
return ViewerContext(project=resolved_project, agent=resolved_agent)


def viewer_from_params(store: KBStore, params: dict[str, Any]) -> ViewerContext:
"""Resolve viewer context from kb.* method params (flat or nested)."""
nested = params.get("viewer_scope")
if isinstance(nested, dict):
return viewer_from(
config_path=store.config_path,
project=nested.get("project"),
agent=nested.get("agent"),
)
return viewer_from(
config_path=store.config_path,
project=params.get("project"),
agent=params.get("agent"),
)


def is_visible(scope: ArtifactScope, viewer: ViewerContext) -> bool:
"""Return whether *scope* is visible to *viewer* in retrieval surfaces."""
vis = scope.visibility
Expand Down Expand Up @@ -134,3 +157,70 @@ def filter_hits(
if limit is not None and len(kept) >= limit:
break
return kept


def _is_sha256_hex(value: str) -> bool:
return len(value) == 64 and all(c in "0123456789abcdef" for c in value)


def _parse_scope(raw: object) -> ArtifactScope:
if raw is None:
return ArtifactScope()
if isinstance(raw, str):
return ArtifactScope(visibility=Visibility(raw))
if isinstance(raw, dict):
return ArtifactScope.model_validate(raw)
return ArtifactScope()


def artifact_scope_for_object_id(store: KBStore, object_id: str) -> ArtifactScope | None:
"""Return scope for an audit ``object_id``; ``None`` if unscoped or unknown."""
if _is_sha256_hex(object_id):
try:
return store.get_source(object_id).scope
except Exception:
pass

try:
return store.get_claim(object_id).scope
except Exception:
pass

try:
proposal = store.get_proposal(object_id)
except Exception:
return None

if proposal.kind != ProposalKind.CLAIM:
return None
Comment thread
claytonlin1110 marked this conversation as resolved.

raw = proposal.payload.get("scope")
return _parse_scope(raw)


def event_visible_to_viewer(
store: KBStore,
event: AuditEvent,
viewer: ViewerContext,
) -> bool:
"""Return whether *event* may be shown to *viewer*.

Events with no ``object_ids`` (e.g. ``kb.init``) are visible to everyone.
Otherwise every referenced scoped artifact must pass ``is_visible``.
"""
if not event.object_ids:
return True
for oid in event.object_ids:
scope = artifact_scope_for_object_id(store, oid)
if scope is not None and not is_visible(scope, viewer):
return False
return True


def filter_audit_events(
store: KBStore,
events: list[AuditEvent],
viewer: ViewerContext,
) -> list[AuditEvent]:
"""Drop audit events whose ``object_ids`` reference artifacts outside *viewer*."""
return [e for e in events if event_visible_to_viewer(store, e, viewer)]
22 changes: 18 additions & 4 deletions src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,10 +708,24 @@ def kb_import_apply(bundle_path: str, on_conflict: str = "skip") -> dict[str, An


@mcp.tool()
def kb_audit(tail: int = 50) -> list[dict[str, Any]]:
"""Return the last N audit events."""
events = list(audit.read_events(_store().kb_dir))[-tail:]
return [e.model_dump(mode="json") for e in events]
def kb_audit(
tail: int = 50,
*,
project: str | None = None,
agent: str | None = None,
Comment thread
claytonlin1110 marked this conversation as resolved.
) -> dict[str, Any]:
"""Return the last N audit events, filtered to the viewer scope."""
store = _store()
viewer = viewer_from(
config_path=store.config_path,
project=project,
agent=agent,
)
events = list(audit.read_events(store.kb_dir, store=store, viewer=viewer))[-tail:]
return {
"viewer": {"project": viewer.project, "agent": viewer.agent},
"events": [e.model_dump(mode="json") for e in events],
}


@mcp.tool()
Expand Down
5 changes: 3 additions & 2 deletions src/vouch/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
import uuid
from datetime import UTC, datetime

from . import audit, index_db, salience, volunteer_context
from . import audit, index_db, volunteer_context
from . import salience as salience_mod
from .models import Page, PageType, ProposalStatus, Session
from .proposals import approve
from .storage import KBStore
Expand All @@ -39,7 +40,7 @@ def session_start(store: KBStore, *, agent: str, task: str | None = None,

def session_end(store: KBStore, session_id: str, *, note: str | None = None) -> Session:
sess = store.get_session(session_id)
salience.reset_session(session_id)
salience_mod.reset_session(session_id)
if sess.ended_at is not None:
return sess # idempotent
sess.ended_at = datetime.now(UTC)
Expand Down
5 changes: 4 additions & 1 deletion src/vouch/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,10 @@ async def contradict(claim_id: str, against: str = Form(...)) -> Any:

@app.get("/audit", response_class=HTMLResponse, dependencies=guarded)
def audit(request: Request, limit: int = 100) -> Any:
events = list(audit_mod.read_events(store.kb_dir))
from ..scoping import viewer_from

viewer = viewer_from(config_path=store.config_path)
events = list(audit_mod.read_events(store.kb_dir, store=store, viewer=viewer))
events.reverse() # newest first
filtered = [e for e in events if _is_review_event(e.event)][:limit]
rows = [
Expand Down
15 changes: 15 additions & 0 deletions tests/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,18 @@ def test_audit_log_survives_malformed_lines(store: KBStore) -> None:
(store.kb_dir / "audit.log.jsonl").open("a").write("garbage\n")
events = list(audit.read_events(store.kb_dir))
assert len(events) == 1


def test_read_events_unfiltered_by_default(store: KBStore) -> None:
from vouch.models import ArtifactScope, Claim, Visibility

src = store.put_source(b"e")
store.put_claim(Claim(
id="secret",
text="x",
evidence=[src.id],
scope=ArtifactScope(visibility=Visibility.PROJECT, project="billing"),
))
audit.log_event(store.kb_dir, event="claim.create", actor="u", object_ids=["secret"])
events = list(audit.read_events(store.kb_dir))
assert any("secret" in e.object_ids for e in events)
Loading
Loading