diff --git a/CHANGELOG.md b/CHANGELOG.md index 65407d84..96385951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `vouch install-mcp claude-code` now registers vouch as a **local-scope** + MCP server in `~/.claude.json` (`projects[].mcpServers`), + the same thing `claude mcp add` does. a committed `.mcp.json` is a + *project*-scope server that Claude Code loads only after a per-user + approval — and the **VS Code extension never surfaces that approval + prompt**, so `.mcp.json` alone left the `kb_*` tools stuck at "pending + approval" in the extension while the hooks quietly ran (reads as + connected, isn't). the local-scope entry is trusted on sight, so a fresh + install now connects after a window reload with no manual step. verified + end-to-end: fresh project → `install-mcp` → `claude mcp list` reports + `✔ Connected` (was `⏸ Pending approval`). declared by a manifest + `user_mcp:` block (host-neutral core; only claude-code opts in); + idempotent and never clobbers a server you added yourself; opt out with + `--no-approve`. + +### Changed +- the starter config now ships `review.auto_approve_on_receipt: true` + (and `require_human_approval: false`, an advisory key no code path + reads): a fresh KB auto-approves captured claims whose byte-offset + receipts verify against their source, so recall works out of the box + with no `vouch review` pass. the gate is unchanged for everything + the receipt check cannot vouch for — pages (session summaries + included), entities, relations, and claims that cannot quote their + source still wait for a human. existing KBs keep whatever their + `.vouch/config.yaml` says; set `auto_approve_on_receipt: false` to + restore the fully human gate. +- the receipt drain now runs at every session start (`capture + finalize-all`), so verifiable claims left pending while the gate was + off are approved instead of stranded, and it is duplicate-safe: a + claim re-deriving text that is already durable is mechanically + rejected ("duplicate: identical claim already durable") rather than + crashing the drain or piling up in the review queue. a claim id held + by *different* text is a real conflict and stays pending for a human. + the same resolution now backs `capture answer`, which previously left + re-captured duplicates pending forever. + ## [1.4.0] — 2026-07-17 ### Added diff --git a/README.md b/README.md index 6a015b71..e4aaaad3 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,9 @@ cd /path/to/your/project vouch install-mcp claude-code # creates .vouch/ (if missing) + wires Claude Code ``` -`install-mcp` initialises the KB when no `.vouch/` is discoverable (pass `--no-init` to skip; `vouch init` still exists for KB-only setup), then writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and five hooks — `SessionStart` recall, `UserPromptSubmit` per-prompt recall, `PostToolUse` capture, `Stop` answer capture, `SessionEnd` rollup. Restart Claude Code so they load. +`install-mcp` initialises the KB when no `.vouch/` is discoverable (pass `--no-init` to skip; `vouch init` still exists for KB-only setup), then writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and five hooks — `SessionStart` recall, `UserPromptSubmit` per-prompt recall, `PostToolUse` capture, `Stop` answer capture, `SessionEnd` rollup. It also registers vouch as a local-scope MCP server in `~/.claude.json` (the `⚑` line in the output). **Reload your editor window** (VS Code: *Developer: Reload Window*) so it loads. + +> **Why the extra registration?** A committed `.mcp.json` is a *project*-scope server, and Claude Code only loads one after a per-user approval — which the **VS Code extension never prompts for**, so `.mcp.json` alone leaves the `kb_*` tools invisible in the extension (they sit at "pending approval", while the hooks quietly work — easy to misread as "connected"). The local-scope entry `install-mcp` writes is trusted on sight, so a fresh install just connects. Verify with `claude mcp list` (`vouch … ✔ Connected`). Pass `--no-approve` to skip it and approve `.mcp.json` yourself. > **One-time approval.** Claude Code only loads a project's `.mcp.json` servers after a per-user approval. The terminal CLI prompts for it on the next `claude` launch — but the **VS Code extension never shows this prompt**, so the `kb.*` tools stay unavailable there while the hooks keep working (which makes it easy to think vouch is connected when it isn't). If `claude mcp list` run inside the project shows `vouch … ⏸ Pending approval`, either run `claude` in the project folder from any terminal and accept the prompt, or create `.claude/settings.local.json` (user-local, not committed) with `{"enabledMcpjsonServers": ["vouch"]}`. The same key in the committed `.claude/settings.json` is deliberately ignored — a repo can't approve its own servers — which is why `install-mcp` can't do this step for you. Verify with `claude mcp list` (`✔ Connected`), then reload the VS Code window. @@ -146,6 +148,8 @@ compile: vouch review # walk pending proposals one at a time ``` +Receipt-verified claims skip the queue by default (`review.auto_approve_on_receipt: true` in the starter config): each session's captured answers become recallable memory with no review pass. What lands in `vouch review` is everything the mechanical check can't vouch for — session-summary pages, entities, relations, and claims that can't quote their source. Set the flag to `false` in `.vouch/config.yaml` to put every write behind the gate. + **Want a browser UI for reviewing and proposing?** The video shows the **vouch webapp** — chat, review queue, claims, and stats. Your options: - **No setup**: the Docker demo above diff --git a/adapters/claude-code/.claude/settings.json b/adapters/claude-code/.claude/settings.json index 8e231f8e..39d3815e 100644 --- a/adapters/claude-code/.claude/settings.json +++ b/adapters/claude-code/.claude/settings.json @@ -68,7 +68,7 @@ ], "Stop": [ { - "comment": "save this turn's answer as durable, recallable knowledge — receipt-backed claims, auto-approved only under the review opt-in (trusted-agent / auto_approve_on_receipt); fires every turn but skips short/duplicate answers; never blocks the turn", + "comment": "save this turn's answer as durable, recallable knowledge — receipt-verified claims auto-approve under the starter-config default (review.auto_approve_on_receipt; set false to keep every write behind vouch review); fires every turn but skips short/duplicate answers; never blocks the turn", "matcher": "*", "hooks": [ { diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md index 980d1490..d7239a53 100644 --- a/adapters/claude-code/README.md +++ b/adapters/claude-code/README.md @@ -14,10 +14,12 @@ pipx install vouch-kb Make sure `vouch` is on the `PATH` Claude Code will see. The one-command path — `vouch install-mcp claude-code` from your project -root — does everything below in one go, and initialises the `.vouch/` KB +root — does everything below in one go, initialises the `.vouch/` KB first when the project doesn't have one yet (`vouch init` also does that -on its own; `--no-init` skips it). The rest of this file is the manual -equivalent. +on its own; `--no-init` skips it), **and registers vouch in +`~/.claude.json` so the VS Code extension loads it without a manual +approval it never prompts for** (see step 2). The rest of this file is +the manual equivalent. ## 2. Drop the MCP server into your project diff --git a/adapters/claude-code/install.yaml b/adapters/claude-code/install.yaml index 6e4f0f99..ef387315 100644 --- a/adapters/claude-code/install.yaml +++ b/adapters/claude-code/install.yaml @@ -9,11 +9,32 @@ # T4 = `.claude/settings.json`: SessionStart (kb status + capture review banner + # recall digest of approved knowledge), PostToolUse (capture observe), # SessionEnd (capture finalize), plus read-only kb_* auto-allow. +# +# user_mcp = a local-scope MCP registration written to the user's +# `~/.claude.json` under `projects[].mcpServers`. The `.mcp.json` +# from T1 is a *project*-scope server, and Claude Code only loads a +# project-scope server after a per-user approval — which the VS Code +# extension never surfaces a prompt for, so `.mcp.json` alone leaves the +# `kb_*` tools invisible in the extension (they sit at "pending approval"). +# A local-scope entry is trusted on sight (no approval gate), so writing one +# is what makes a fresh install actually connect in the extension. This is +# exactly what `claude mcp add vouch -- vouch serve` does; install-mcp does +# it for you (opt out with `--no-approve`). host: claude-code pretty: Claude Code fence: begin: "" end: "" +user_mcp: + config: .claude.json # relative to the user's home directory + scope: project # keyed under projects[].mcpServers + name: vouch + spec: + type: stdio + command: vouch + args: ["serve"] + env: + VOUCH_AGENT: claude-code tiers: T1: - { src: .mcp.json, dst: .mcp.json } diff --git a/docs/getting-started.md b/docs/getting-started.md index b1003078..30cf81ea 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -95,11 +95,16 @@ vouch approve prop-abc123 --reason "matches the meeting notes" The claim is now durable. The proposal moves to `.vouch/decided/prop-abc123.yaml` (committed, for audit). -**Note on approval:** By default, vouch requires human approval and -prevents self-approval (a proposer cannot approve their own proposal). -For local testing, you can add `approver_role: trusted-agent` to -`.vouch/config.yaml`. Production deployments should keep the default -`require_human_approval: true` to preserve the review gate. +**Note on approval:** vouch prevents blanket self-approval (a proposer +cannot approve their own proposal), with one mechanical exception that +the starter config enables by default: a claim whose byte-offset +receipts all verify against their source is auto-approved — the receipt +is the reviewer (`review.auto_approve_on_receipt: true`). Everything +else — pages, entities, relations, and any claim that cannot quote its +source — still waits for a human. Set `auto_approve_on_receipt: false` +in `.vouch/config.yaml` to put every write behind `vouch review`, or +`approver_role: trusted-agent` to let an agent approve anything (local +testing only). ```bash git add .vouch && git commit -m "kb: approve auth-uses-jwt" diff --git a/src/vouch/capture.py b/src/vouch/capture.py index ddfdf025..6a9246b3 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -481,8 +481,9 @@ def capture_answer( receipt-backed claim per quotable span (``extract.extract_receipt_claims``), and approves each one the review gate allows — self-approval clears under ``review.approver_role: trusted-agent`` or, for these verbatim-quoting - claims, ``review.auto_approve_on_receipt``. With neither gate on the claims - stay pending: the review gate is honoured, never bypassed. + claims, ``review.auto_approve_on_receipt`` (the starter-config default). + With neither gate on the claims stay pending: the review gate is + honoured, never bypassed. Idempotent and quiet by design: an answer already ingested (same bytes) is skipped, and answers shorter than ``min_answer_chars`` (acknowledgements) @@ -493,7 +494,6 @@ def capture_answer( from . import extract as extract_mod from . import proposals as proposals_mod - from .proposals import ProposalError from .storage import ArtifactNotFoundError, sha256_hex # vouch's own LLM subprocesses set this so the agent session they spawn does @@ -531,15 +531,14 @@ def capture_answer( ) approved = 0 for result in filed: - try: - proposals_mod.approve( - store, result.proposal.id, approved_by=ANSWER_ACTOR, - reason="auto-captured session answer (receipt verified)", - ) + # approves under the gate, rejects duplicates of durable claims, + # leaves everything else pending — see resolve_pending_receipt_claim. + claim = proposals_mod.resolve_pending_receipt_claim( + store, result.proposal, actor=ANSWER_ACTOR, + reason="auto-captured session answer (receipt verified)", + ) + if claim is not None: approved += 1 - except ProposalError: - # gate closed (no trusted-agent, no receipt opt-in): leave pending. - pass return { "captured": True, "skipped": None, "session_id": session_id, "source": source.id, "filed": len(filed), "approved": approved, @@ -568,6 +567,23 @@ def is_stale_buffer( return age > max_age_seconds +def _drain_receipt_backlog(store: KBStore) -> int: + """Auto-approve pending receipt-verified claims; count them. + + With ``review.auto_approve_on_receipt`` on (the starter-config default) + this clears any backlog of verifiable claims left pending while the gate + was off — e.g. a kb that flipped the flag after capturing. No-op when the + gate is off, and never fatal: it runs from a SessionStart hook that must + not break the session. + """ + from . import proposals as proposals_mod + + try: + return len(proposals_mod.auto_approve_receipts(store)) + except Exception: + return 0 + + def finalize_all_except( store: KBStore, current_session_id: str, @@ -582,6 +598,7 @@ def finalize_all_except( - finalized: [session_id1, session_id2, ...] session IDs that were finalized - skipped_recent: [id3, id4, ...] sessions too recent to finalize - skipped_current: [id5] the current session (always skipped) + - auto_approved: count of pending receipt-verified claims drained on the way """ finalized: list[str] = [] skipped_recent: list[str] = [] @@ -594,6 +611,7 @@ def finalize_all_except( "finalized": finalized, "skipped_recent": skipped_recent, "skipped_current": skipped_current, + "auto_approved": _drain_receipt_backlog(store), } for path in sorted(caps_dir.glob("*.jsonl")): @@ -621,4 +639,5 @@ def finalize_all_except( "finalized": finalized, "skipped_recent": skipped_recent, "skipped_current": skipped_current, + "auto_approved": _drain_receipt_backlog(store), } diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 2ca65bca..0454f4d0 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2419,8 +2419,9 @@ def capture_answer_cmd(session_id: str | None) -> None: The Stop hook emits {session_id, transcript_path} on stdin; the session's answer is ingested as a source and its receipt-backed claims are - auto-approved under the review opt-in (trusted-agent / auto_approve_on_receipt), - else left pending. Always exits 0 so a capture failure can never break the turn. + auto-approved when review.auto_approve_on_receipt is on (the + starter-config default) or under trusted-agent, else left pending. + Always exits 0 so a capture failure can never break the turn. """ if sys.stdin.isatty(): return @@ -3923,6 +3924,15 @@ def pr_cache_show(repo: str, state: str, limit: int, as_json: bool, cache_dir: s help="Initialise a .vouch/ KB at the target when none is discoverable, " "so install-mcp is a one-command setup.", ) +@click.option( + "--approve/--no-approve", + "approve", + default=True, + show_default=True, + help="Register vouch as a local-scope MCP server in ~/.claude.json so the " + "Claude Code extension loads it without a manual approval it never prompts " + "for. --no-approve leaves only the project .mcp.json (needs manual approval).", +) def install_mcp( host: str | None, list_hosts: bool, @@ -3930,6 +3940,7 @@ def install_mcp( target_alias: str | None, tier: str, auto_init: bool, + approve: bool, ) -> None: """Install vouch into HOST (claude-code, cursor, …) idempotently. @@ -4006,7 +4017,7 @@ def install_mcp( if kb_root != target: click.echo(f"Using existing KB at {kb_root / '.vouch'}") try: - result = install_mod.install(host, target=target, tier=tier) + result = install_mod.install(host, target=target, tier=tier, approve=approve) except install_mod.AdapterError as e: raise click.ClickException(str(e)) from e @@ -4016,6 +4027,8 @@ def install_mcp( click.echo(f" ~ {f} (appended fenced block)") for f in result.merged: click.echo(f" ~ {f} (merged into existing)") + for f in result.registered: + click.echo(f" ⚑ {f} (registered in ~/.claude.json)") for f in result.skipped: click.echo(f" · {f} (already present)") for f in result.failed: @@ -4023,9 +4036,23 @@ def install_mcp( click.echo( f"Done — {len(result.written)} written, " f"{len(result.appended)} appended, {len(result.merged)} merged, " + f"{len(result.registered)} registered, " f"{len(result.skipped)} skipped, {len(result.failed)} failed " f"under {target}" ) + if result.registered: + # the extension reads MCP servers once, at window start — a running + # window won't see the new registration until it reloads. + click.echo( + "Reload your editor window so it picks up the vouch MCP server " + "(VS Code: Developer: Reload Window)." + ) + elif approve and host == "claude-code" and not result.failed: + # already registered on a prior run — still remind, harmlessly. + click.echo( + "vouch is registered for Claude Code. If the kb_* tools aren't " + "visible, reload your editor window." + ) if result.failed: # a failed install is not a no-op: exit non-zero so scripts (and the # user) notice vouch was NOT wired into these files. diff --git a/src/vouch/install_adapter.py b/src/vouch/install_adapter.py index 06d45529..184c7304 100644 --- a/src/vouch/install_adapter.py +++ b/src/vouch/install_adapter.py @@ -78,6 +78,26 @@ class InstallResult: # from ``skipped`` so "already installed" and "install failed" never look # the same to the caller / CLI. failed: list[str] = field(default_factory=list) + # user-scope MCP servers registered outside the target tree (in the host's + # user config, e.g. ~/.claude.json). Reported separately because the write + # lands outside `target` and, unlike the tier files, is a config the host + # engine reads on launch rather than a file the user edits. + registered: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class _UserMcp: + """A local-scope MCP registration declared by an adapter's manifest. + + Written to ``/`` (e.g. ``~/.claude.json``). ``scope: project`` + keys the server under ``projects[].mcpServers``; ``scope: user`` + puts it in the top-level ``mcpServers``. Idempotent: an existing server of + the same ``name`` is never overwritten. + """ + config: str # path relative to the user's home dir + scope: str # "project" | "user" + name: str # server key + spec: dict[str, Any] # the server definition (command/args/env/…) @dataclass(frozen=True) @@ -96,6 +116,7 @@ class _Manifest: tiers: dict[str, list[_FileEntry]] fence_begin: str = _DEFAULT_FENCE_BEGIN fence_end: str = _DEFAULT_FENCE_END + user_mcp: _UserMcp | None = None # false for hosts whose install target is a staging dir for user-global # config (claude-desktop) rather than project wiring — a KB bootstrapped # at the target would land wherever the user happened to be standing. @@ -205,10 +226,40 @@ def _flag(name: str, raw: Any = raw, tier_name: str = tier_name) -> bool: tiers=parsed, fence_begin=fence_begin, fence_end=fence_end, + user_mcp=_parse_user_mcp(host, data.get("user_mcp")), kb_bootstrap=kb_bootstrap, ) +def _parse_user_mcp(host: str, raw: Any) -> _UserMcp | None: + """Parse an optional ``user_mcp:`` manifest block, or return None.""" + if raw is None: + return None + if not isinstance(raw, dict): + raise AdapterError(f"{host}: install.yaml `user_mcp:` must be a mapping") + config = raw.get("config") + if not isinstance(config, str) or not config.strip(): + raise AdapterError(f"{host}: install.yaml user_mcp needs a non-empty `config`") + # `config` names a file in the user's home dir; a `..`/absolute value would + # let a manifest scribble anywhere on disk. Keep it a plain relative name. + if config != Path(config).name or config in {"", ".", ".."}: + raise AdapterError( + f"{host}: install.yaml user_mcp `config` must be a bare filename, got {config!r}" + ) + scope = raw.get("scope", "project") + if scope not in {"project", "user"}: + raise AdapterError( + f"{host}: install.yaml user_mcp `scope` must be 'project' or 'user', got {scope!r}" + ) + name = raw.get("name") + if not isinstance(name, str) or not name.strip(): + raise AdapterError(f"{host}: install.yaml user_mcp needs a non-empty `name`") + spec = raw.get("spec") + if not isinstance(spec, dict): + raise AdapterError(f"{host}: install.yaml user_mcp `spec` must be a mapping") + return _UserMcp(config=config, scope=scope, name=name, spec=spec) + + def wants_kb_bootstrap(host: str) -> bool: """Whether ``install-mcp`` may bootstrap a KB at this host's target. @@ -232,12 +283,26 @@ def available_adapters() -> list[str]: return sorted(out) -def install(adapter: str, *, target: Path, tier: str = "T4") -> InstallResult: +def install( + adapter: str, + *, + target: Path, + tier: str = "T4", + approve: bool = True, + home: Path | None = None, +) -> InstallResult: """Install ``adapter``'s templates under ``target`` up to ``tier``. The call is idempotent: rerunning against a previously-installed tree produces an :class:`InstallResult` with everything in ``skipped`` and nothing in ``written`` / ``appended``. + + When the adapter manifest declares a ``user_mcp:`` block and ``approve`` is + true, a local-scope MCP server is registered in the host's user config + (``home``/````, default ``~/.claude.json``). That is what makes a + fresh install connect in the Claude Code *extension*, whose engine ignores + an unapproved project ``.mcp.json``. Pass ``approve=False`` to skip it. + ``home`` overrides the user-config directory (tests point it at a tmp dir). """ if tier not in _TIER_ORDER: raise AdapterError( @@ -302,9 +367,71 @@ def install(adapter: str, *, target: Path, tier: str = "T4") -> InstallResult: shutil.copy2(src, dst) result.written.append(entry.dst) + if approve and manifest.user_mcp is not None: + _register_user_mcp(manifest.user_mcp, target=target, home=home, result=result) + return result +def _register_user_mcp( + reg: _UserMcp, *, target: Path, home: Path | None, result: InstallResult +) -> None: + """Merge a local-scope MCP server into the host user config (idempotent). + + Mirrors ``claude mcp add``: writes ``/`` with the server + under ``projects[].mcpServers`` (scope=project) or the top-level + ``mcpServers`` (scope=user). Every other key in the file is preserved and + the write is atomic. Failures are recorded (``result.failed``), never + raised — a config we can't safely rewrite must not abort a working install. + """ + cfg_path = (home or Path.home()) / reg.config + label = f"{reg.config}:{reg.name} (mcp)" + try: + if cfg_path.exists(): + raw = cfg_path.read_text(encoding="utf-8") + data = json.loads(raw) if raw.strip() else {} + if not isinstance(data, dict): + result.failed.append(label) + return + else: + data = {} + + if reg.scope == "project": + projects = data.setdefault("projects", {}) + if not isinstance(projects, dict): + result.failed.append(label) + return + container = projects.setdefault(str(target), {}) + else: + container = data + if not isinstance(container, dict): + result.failed.append(label) + return + servers = container.setdefault("mcpServers", {}) + if not isinstance(servers, dict): + result.failed.append(label) + return + + if reg.name in servers: + # already registered — never clobber the user's own edits, and stay + # out of `result.skipped` (a file-path channel; this is a config + # entry, not a file). The CLI reminds the user to reload regardless. + return + servers[reg.name] = dict(reg.spec) + + cfg_path.parent.mkdir(parents=True, exist_ok=True) + tmp = cfg_path.with_name(cfg_path.name + ".vouch-tmp") + tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + if cfg_path.exists(): + shutil.copymode(cfg_path, tmp) + tmp.replace(cfg_path) + result.registered.append(label) + except (OSError, ValueError): + # malformed json, unreadable/unwritable file — surface as failed, but + # the tier files are already installed, so don't unwind the whole run. + result.failed.append(label) + + def _install_fenced( src: Path, dst: Path, diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index ef90cbad..501439cd 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -478,33 +478,74 @@ def _approval_block_reason( return None +def resolve_pending_receipt_claim( + store: KBStore, proposal: Proposal, *, actor: str, reason: str +) -> Claim | None: + """Mechanically decide one pending CLAIM proposal, honouring the gate. + + Returns the durable Claim when self-approval clears — under + ``review.approver_role: trusted-agent``, or when the claim's byte-offset + receipts all verify under ``review.auto_approve_on_receipt``. Returns None + when the proposal stays pending (gate closed, receipts unverifiable, or + its id is held by a claim with *different* text — a real conflict, a human + call) or when it was rejected as a duplicate: re-deriving a claim whose + identical text is already durable adds nothing, so the proposal is closed + with a duplicate reason instead of piling up in the review queue. + """ + if proposal.kind != ProposalKind.CLAIM: + return None + review_cfg = _review_config(store) + trusted = review_cfg.get("approver_role") == "trusted-agent" + receipted = bool( + review_cfg.get("auto_approve_on_receipt") + ) and _claim_receipts_verify(store, proposal) + if not (trusted or receipted): + return None + claim_id = str(proposal.payload.get("id", "")) + existing: Claim | None = None + if claim_id: + try: + existing = store.get_claim(claim_id) + except ArtifactNotFoundError: + existing = None + if existing is not None: + if existing.text == proposal.payload.get("text"): + reject( + store, proposal.id, rejected_by=actor, + reason="duplicate: identical claim already durable", + ) + return None + result = approve(store, proposal.id, approved_by=actor, reason=reason) + assert isinstance(result, Claim) # kind == CLAIM guaranteed above + return result + + def auto_approve_receipts( store: KBStore, *, actor: str | None = None ) -> list[Claim]: """Approve every pending receipt-verified claim, no human in the loop. The mechanical gate is the reviewer: a pending CLAIM whose citations all - carry receipts that verify by string comparison is approved; anything else - — a bare source id, a forged or missing receipt, a non-claim proposal — is - left pending for a human. This is the drain that makes "run vouch and it - just captures knowledge" real. No-op unless ``review.auto_approve_on_receipt`` - is set, so the human-review gate is never silently bypassed. + carry receipts that verify by string comparison is approved; a duplicate + of an already-durable identical claim is rejected (see + ``resolve_pending_receipt_claim``); anything else — a bare source id, a + forged or missing receipt, a non-claim proposal, an id held by different + text — is left pending for a human. This is the drain that makes "run + vouch and it just captures knowledge" real. No-op unless + ``review.auto_approve_on_receipt`` is set, so the human-review gate is + never silently bypassed. """ if not _review_config(store).get("auto_approve_on_receipt"): return [] approved: list[Claim] = [] for proposal in store.list_proposals(ProposalStatus.PENDING): - if proposal.kind != ProposalKind.CLAIM or not _claim_receipts_verify( - store, proposal - ): - continue - result = approve( - store, proposal.id, - approved_by=actor or proposal.proposed_by, + claim = resolve_pending_receipt_claim( + store, proposal, + actor=actor or proposal.proposed_by, reason="receipt verified — auto-approved", ) - assert isinstance(result, Claim) # kind == CLAIM guaranteed above - approved.append(result) + if claim is not None: + approved.append(claim) return approved diff --git a/src/vouch/storage.py b/src/vouch/storage.py index f3fb1552..b82c7171 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -77,13 +77,14 @@ def _starter_config() -> dict[str, Any]: return { "version": KB_FORMAT_VERSION, "review": { - "require_human_approval": True, + "require_human_approval": False, "expire_pending_after_days": 90, # phase d — the receipt is the reviewer. When true, a claim whose # byte-offset receipts all verify is auto-approved with no human; - # a claim that cannot quote its source is left pending. Opt-in: - # the human-review gate stays on by default. - "auto_approve_on_receipt": False, + # a claim that cannot quote its source is left pending, as is + # every page/entity/relation proposal. Set false to put every + # write behind `vouch review`. + "auto_approve_on_receipt": True, }, "capture": { # auto-capture agent sessions into pending summaries. diff --git a/tests/test_capture.py b/tests/test_capture.py index e40ca0ed..2a7ca4ee 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -40,6 +40,23 @@ def test_starter_config_has_capture_namespace() -> None: assert _starter_config()["capture"]["enabled"] is True +def test_finalize_all_drains_receipt_backlog(store: KBStore) -> None: + # a verifiable claim left pending (e.g. filed while the gate was off) is + # drained on the next session start, not stranded in the queue forever. + from vouch.models import ProposalStatus + from vouch.proposals import propose_quoted_claim + + src = store.put_source(b"the pipeline has four stages") + res = propose_quoted_claim( + store, text="the pipeline has four stages", source_id=src.id, + quote="the pipeline has four stages", proposed_by="agent-a", + ) + assert res is not None + result = cap.finalize_all_except(store, "current-session") + assert result["auto_approved"] == 1 + assert store.get_proposal(res.id).status is ProposalStatus.APPROVED + + def test_init_gitignores_captures(tmp_path: Path) -> None: kb = KBStore.init(tmp_path) assert "captures/" in (kb.kb_dir / ".gitignore").read_text() diff --git a/tests/test_capture_answer.py b/tests/test_capture_answer.py index ed87e3ec..2b918881 100644 --- a/tests/test_capture_answer.py +++ b/tests/test_capture_answer.py @@ -135,7 +135,10 @@ def test_capture_answer_approves_under_trusted_agent(store: KBStore, tmp_path: P def test_capture_answer_leaves_pending_when_gate_off(store: KBStore, tmp_path: Path) -> None: - # default starter config: neither opt-in set. + # both opt-ins explicitly off: every capture waits for a human. + store.config_path.write_text( + "review:\n auto_approve_on_receipt: false\n", encoding="utf-8" + ) tp = _transcript(tmp_path, [_user(QUESTION), _assistant(ANSWER)]) res = cap.capture_answer(store, "sess-1", tp) assert res["captured"] is True @@ -146,6 +149,30 @@ def test_capture_answer_leaves_pending_when_gate_off(store: KBStore, tmp_path: P assert len(pending) >= 3 +def test_capture_answer_recapture_leaves_no_pending_duplicates( + store: KBStore, tmp_path: Path +) -> None: + # a later answer restating already-durable facts must not pile up pending + # duplicates -- they are closed mechanically (rejected), fresh facts land. + tp1 = _transcript(tmp_path, [_user(QUESTION), _assistant(ANSWER)]) + first = cap.capture_answer(store, "sess-1", tp1) + assert first["approved"] == first["filed"] >= 3 + assert cap.pending_count(store) == 0 + + extra = ( + "The observation buffer feeds passive capture across every host " + "adapter vouch ships." + ) + tp2 = _transcript(tmp_path, [_user(QUESTION), _assistant(ANSWER + " " + extra)]) + second = cap.capture_answer(store, "sess-2", tp2) + assert second["captured"] is True + # nothing waits for a human: fresh claims approved, restated ones rejected + # as duplicates of durable claims. + assert cap.pending_count(store) == 0 + rejected = store.list_proposals(ProposalStatus.REJECTED) + assert any("duplicate" in (p.decision_reason or "") for p in rejected) + + def test_capture_answer_skips_short_answer(store: KBStore, tmp_path: Path) -> None: _enable_receipt_gate(store) tp = _transcript(tmp_path, [_user(QUESTION), _assistant("done.")]) diff --git a/tests/test_extract.py b/tests/test_extract.py index 1af16bba..270a4d2f 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -71,6 +71,9 @@ def test_ingest_source_auto_approves_and_is_recallable(store: KBStore) -> None: def test_ingest_source_leaves_pending_when_gate_off(store: KBStore) -> None: + store.config_path.write_text( + "review:\n auto_approve_on_receipt: false\n", encoding="utf-8" + ) _src, approved = extract.ingest_source(store, SOURCE, proposed_by="agent") assert approved == [] # proposed and receipt-backed, but still waiting for a human. diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index 035a82f7..c8438d84 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -35,6 +35,20 @@ REPO_ROOT = Path(__file__).resolve().parent.parent +@pytest.fixture(autouse=True) +def _sandbox_home(tmp_path_factory, monkeypatch): + """Redirect ``Path.home()`` to a throwaway dir for every test in this module. + + ``install()`` now writes a local-scope MCP entry to ``~/.claude.json`` by + default (``approve=True``). Without this, the many tests (and CLI tests) + that call ``install("claude-code", …)`` with no explicit ``home`` would + scribble a ``projects[]`` entry into the developer's real config. + """ + fake_home = tmp_path_factory.mktemp("home") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + return fake_home + + # --- catalogue ------------------------------------------------------------ @@ -1194,3 +1208,122 @@ def test_install_rejects_dst_escaping_target( with pytest.raises(AdapterError, match=r"escape|outside|traversal"): install("evil", target=target, tier="T1") assert not (tmp_path / "escape.txt").exists() + + +# --- user_mcp: local-scope registration in ~/.claude.json ------------------ +# +# The Claude Code VS Code extension will not load a project-scope `.mcp.json` +# server until it is approved, and it never surfaces the approval prompt — so a +# fresh install leaves the kb_* tools invisible. install-mcp also writes a +# *local-scope* entry (projects[].mcpServers) which is trusted on sight, +# exactly what `claude mcp add` does. These tests pin that behaviour with a +# sandboxed `home` so the real ~/.claude.json is never touched. + +_WORKING_SPEC = { + "type": "stdio", + "command": "vouch", + "args": ["serve"], + "env": {"VOUCH_AGENT": "claude-code"}, +} + + +def _claude_json(home: Path) -> dict: + return json.loads((home / ".claude.json").read_text(encoding="utf-8")) + + +def test_install_registers_local_scope_mcp(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + result = install("claude-code", target=target, tier="T4", home=home) + assert any("mcp" in r for r in result.registered) + entry = _claude_json(home)["projects"][str(target.resolve())]["mcpServers"]["vouch"] + # byte-identical to the entry that is proven to load in the extension + assert entry == _WORKING_SPEC + + +def test_install_mcp_registration_is_idempotent(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + install("claude-code", target=target, tier="T4", home=home) + before = (home / ".claude.json").read_text(encoding="utf-8") + second = install("claude-code", target=target, tier="T4", home=home) + after = (home / ".claude.json").read_text(encoding="utf-8") + assert second.registered == [] # nothing new written + assert before == after # file untouched, no dup + + +def test_no_approve_writes_nothing_to_claude_json(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + result = install("claude-code", target=target, tier="T4", approve=False, home=home) + assert result.registered == [] + assert not (home / ".claude.json").exists() + + +def test_registration_preserves_existing_claude_json(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + (home / ".claude.json").write_text( + json.dumps({"numStartups": 5, "projects": {"/other": {"mcpServers": {"x": {}}}}}), + encoding="utf-8", + ) + install("claude-code", target=target, tier="T1", home=home) + data = _claude_json(home) + assert data["numStartups"] == 5 # untouched top-level + assert "x" in data["projects"]["/other"]["mcpServers"] # untouched other project + assert "vouch" in data["projects"][str(target.resolve())]["mcpServers"] + + +def test_registration_never_clobbers_user_server(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + mine = {"command": "my-own-vouch"} + (home / ".claude.json").write_text( + json.dumps({"projects": {str(target.resolve()): {"mcpServers": {"vouch": mine}}}}), + encoding="utf-8", + ) + result = install("claude-code", target=target, tier="T1", home=home) + assert result.registered == [] + kept = _claude_json(home)["projects"][str(target.resolve())]["mcpServers"]["vouch"] + assert kept == mine + + +def test_malformed_claude_json_is_reported_not_raised(tmp_path: Path) -> None: + target, home = tmp_path / "proj", tmp_path / "home" + target.mkdir() + home.mkdir() + (home / ".claude.json").write_text("{ not valid json", encoding="utf-8") + result = install("claude-code", target=target, tier="T1", home=home) + # tier files still install; the bad config is surfaced as failed, not a crash + assert ".mcp.json" in result.written + assert any("mcp" in f for f in result.failed) + + +def test_user_mcp_manifest_rejects_traversal_config(tmp_path: Path, monkeypatch) -> None: + import vouch.install_adapter as ia + + adapters = tmp_path / "adapters" + (adapters / "evilmcp").mkdir(parents=True) + (adapters / "evilmcp" / "payload.txt").write_text("x", encoding="utf-8") + (adapters / "evilmcp" / "install.yaml").write_text( + "host: evilmcp\n" + "pretty: Evil\n" + "tiers:\n" + " T1:\n" + " - { src: payload.txt, dst: payload.txt }\n" + "user_mcp:\n" + " config: ../../escape.json\n" + " name: x\n" + " spec: { command: x }\n", + encoding="utf-8", + ) + monkeypatch.setattr(ia, "ADAPTERS_DIR", adapters) + target = tmp_path / "project" + target.mkdir() + with pytest.raises(AdapterError, match=r"bare filename"): + install("evilmcp", target=target, tier="T1", home=tmp_path / "home") diff --git a/tests/test_receipt_auto_approve.py b/tests/test_receipt_auto_approve.py index 69ff531a..7d972016 100644 --- a/tests/test_receipt_auto_approve.py +++ b/tests/test_receipt_auto_approve.py @@ -35,6 +35,12 @@ def _enable_receipt_gate(store: KBStore) -> None: ) +def _disable_receipt_gate(store: KBStore) -> None: + store.config_path.write_text( + "review:\n auto_approve_on_receipt: false\n", encoding="utf-8" + ) + + def test_receipt_verified_claim_self_approves_when_gate_on(store: KBStore) -> None: _enable_receipt_gate(store) src = store.put_source(b"the sky is blue today") @@ -61,8 +67,23 @@ def test_bare_source_claim_still_needs_human_when_gate_on(store: KBStore) -> Non approve(store, res.id, approved_by="agent-a") +def test_receipt_gate_on_by_default(store: KBStore) -> None: + # the starter config ships with auto_approve_on_receipt: true -- a fresh + # kb lets a verifying receipt clear self-approval with no config edit. + src = store.put_source(b"the sky is blue today") + res = propose_quoted_claim( + store, text="the sky is blue", source_id=src.id, + quote="the sky is blue", proposed_by="agent-a", + ) + assert res is not None + claim = approve(store, res.id, approved_by="agent-a") + assert store.get_proposal(res.id).status is ProposalStatus.APPROVED + assert store.get_claim(claim.id).text == "the sky is blue" + + def test_receipt_claim_blocked_when_gate_off(store: KBStore) -> None: - # default config: gate off -- a verifying receipt does not grant self-approval. + # gate explicitly off -- a verifying receipt does not grant self-approval. + _disable_receipt_gate(store) src = store.put_source(b"the sky is blue") res = propose_quoted_claim( store, text="the sky is blue", source_id=src.id, @@ -108,9 +129,54 @@ def test_auto_approve_receipts_drains_verified_leaves_unverified( def test_auto_approve_receipts_noop_when_gate_off(store: KBStore) -> None: + _disable_receipt_gate(store) src = store.put_source(b"alpha beta") propose_quoted_claim( store, text="mentions beta", source_id=src.id, quote="beta", proposed_by="agent-a", ) assert auto_approve_receipts(store) == [] + + +def test_auto_approve_receipts_rejects_duplicate_of_durable_claim( + store: KBStore, +) -> None: + # a session re-deriving a fact that is already durable must not crash the + # drain or pile up in the queue -- the duplicate is closed mechanically. + src = store.put_source(b"alpha beta gamma") + first = propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", + ) + assert first is not None + assert len(auto_approve_receipts(store)) == 1 + + again = propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", + ) + assert again is not None + assert auto_approve_receipts(store) == [] + decided = store.get_proposal(again.id) + assert decided.status is ProposalStatus.REJECTED + assert "duplicate" in (decided.decision_reason or "") + + +def test_auto_approve_receipts_leaves_id_conflict_pending(store: KBStore) -> None: + # same claim id, different text: not a duplicate but a conflict -- the + # drain never overwrites or rejects it, a human decides. + src = store.put_source(b"alpha beta gamma") + first = propose_quoted_claim( + store, text="mentions beta", source_id=src.id, quote="beta", + proposed_by="agent-a", slug_hint="shared-id", + ) + assert first is not None + assert len(auto_approve_receipts(store)) == 1 + + other = propose_quoted_claim( + store, text="mentions gamma", source_id=src.id, quote="gamma", + proposed_by="agent-a", slug_hint="shared-id", + ) + assert other is not None + assert auto_approve_receipts(store) == [] + assert store.get_proposal(other.id).status is ProposalStatus.PENDING