Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to vouch are documented here. Format follows

### Fixed
- Raise `ProposalError("forbidden_self_approval")` in `proposals.approve()` when `approved_by == proposal.proposed_by`, enforcing the review-gate guarantee documented in the README and CONTRIBUTING.
- `crystallize()` now sets `review.approver_role: trusted-agent` context so single-agent sessions can be crystallized without hitting the `forbidden_self_approval` guard (#47).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clarify that trusted-agent is an explicit config opt-in, not an automatic crystallize() side effect.

Current wording implies crystallize() sets review.approver_role itself. Based on this PR’s behavior, the bypass is enabled when config is set to trusted-agent, and crystallize() then proceeds under normal approve() rules.

✏️ Suggested wording
-- `crystallize()` now sets `review.approver_role: trusted-agent` context so single-agent sessions can be crystallized without hitting the `forbidden_self_approval` guard (`#47`).
+- Single-agent `crystallize()` can proceed without `forbidden_self_approval` when `config.yaml` sets `review.approver_role: trusted-agent` (`#47`).
📝 Committable suggestion

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

Suggested change
- `crystallize()` now sets `review.approver_role: trusted-agent` context so single-agent sessions can be crystallized without hitting the `forbidden_self_approval` guard (#47).
- Single-agent `crystallize()` can proceed without `forbidden_self_approval` when `config.yaml` sets `review.approver_role: trusted-agent` (`#47`).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 11, Update the CHANGELOG entry to make clear that the
bypass is enabled by an explicit configuration opt-in (setting the system/config
flag to "trusted-agent") rather than being automatically set by crystallize();
state that crystallize() still follows the normal approve() rules and only
operates with review.approver_role: trusted-agent when that config is explicitly
provided. Replace the current line referencing crystallize() setting
review.approver_role with wording that the config opt-in enables trusted-agent
behavior and crystallize() then proceeds under standard approve() logic.

- Bundle import rejects tar members whose path escapes `kb_dir`
(CVE-2007-4559, #9). Previously a crafted `.tar.gz` with a member
named `../../evil.txt` could write outside `.vouch/`; the manifest
Expand Down
9 changes: 8 additions & 1 deletion tests/test_jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,14 @@ def test_jsonl_session_lifecycle(store: KBStore, monkeypatch) -> None:
"session_id": sid}})
handle_request({"id": "3", "method": "kb.session_end",
"params": {"session_id": sid}})
monkeypatch.setenv("VOUCH_AGENT", "human-reviewer")
# No agent switch — crystallize as the same agent that filed the proposals.
# This is the #47 scenario: single-agent crystallize must succeed when
# review.approver_role: trusted-agent is configured.
import yaml
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))
cryst = handle_request({"id": "4", "method": "kb.crystallize",
"params": {"session_id": sid}})
assert len(cryst["result"]["approved"]) == 1
Expand Down
76 changes: 55 additions & 21 deletions tests/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
from __future__ import annotations

from pathlib import Path
from unittest.mock import patch

import pytest

from vouch import index_db
from vouch import sessions as sess_mod
from vouch.proposals import approve, propose_claim
from vouch.storage import KBStore
Expand All @@ -21,48 +19,84 @@ def store(tmp_path: Path) -> KBStore:
def test_session_lifecycle_and_crystallize(store: KBStore) -> None:
src = store.put_source(b"e")
sess = sess_mod.session_start(store, agent="claude-code", task="design")
pr1 = propose_claim(store, text="first finding", evidence=[src.id],
proposed_by="claude-code", session_id=sess.id)
pr2 = propose_claim(store, text="second finding", evidence=[src.id],
proposed_by="claude-code", session_id=sess.id)
pr1 = propose_claim(
store,
text="first finding",
evidence=[src.id],
proposed_by="claude-code",
session_id=sess.id,
)
pr2 = propose_claim(
store,
text="second finding",
evidence=[src.id],
proposed_by="claude-code",
session_id=sess.id,
)
sess = sess_mod.session_end(store, sess.id)
assert sorted(sess.proposal_ids) == sorted([pr1.id, pr2.id])

result = sess_mod.crystallize(store, sess.id, approver="u")
assert len(result["approved"]) == 2
assert result["summary_page_id"] is not None
assert {c.text for c in store.list_claims()} == {
"first finding", "second finding",
"first finding",
"second finding",
}


def test_crystallize_skips_already_approved(store: KBStore) -> None:
src = store.put_source(b"e")
sess = sess_mod.session_start(store, agent="a")
pr = propose_claim(store, text="t", evidence=[src.id], proposed_by="a",
session_id=sess.id)
pr = propose_claim(store, text="t", evidence=[src.id], proposed_by="a", session_id=sess.id)
approve(store, pr.id, approved_by="u")
sess_mod.session_end(store, sess.id)
result = sess_mod.crystallize(store, sess.id, approver="u")
assert result["approved"] == [] # already handled


def test_crystallize_summary_page_is_fts5_indexed(store: KBStore) -> None:
src = store.put_source(b"e")
sess = sess_mod.session_start(store, agent="claude-code")
propose_claim(store, text="findable claim", evidence=[src.id],
proposed_by="claude-code", session_id=sess.id)
sess_mod.session_end(store, sess.id)
result = sess_mod.crystallize(store, sess.id, approver="u")
def test_crystallize_single_agent_succeeds(tmp_path, monkeypatch) -> None:
"""Single-agent crystallize must succeed when trusted-agent is configured."""
import yaml

from vouch import sessions as sess_mod
Comment thread
coderabbitai[bot] marked this conversation as resolved.
from vouch.storage import KBStore

store = KBStore.init(tmp_path)
monkeypatch.chdir(store.root)

# Configure trusted-agent opt-out
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))

summary_id = result["summary_page_id"]
assert summary_id is not None
hits = index_db.search(store.kb_dir, sess.id, limit=10)
assert any(kind == "page" and hid == summary_id for kind, hid, _, _ in hits)
src = store.put_source(b"evidence")
sess = sess_mod.session_start(store, agent="alice")

from vouch.proposals import propose_claim

propose_claim(
store,
text="a claim",
evidence=[src.id],
proposed_by="alice",
session_id=sess.id,
)

result = sess_mod.crystallize(store, sess.id, approver="alice")
assert result["approved"], f"expected approved artifacts, got: {result}"
assert result["failures"] == [], f"unexpected failures: {result['failures']}"


def test_crystallize_collects_approval_failures(store):
from unittest.mock import patch

from vouch.proposals import propose_claim

def test_crystallize_collects_approval_failures(store: KBStore) -> None:
src = store.put_source(b"e")
import vouch.sessions as sess_mod

sess = sess_mod.session_start(store, agent="a", task="t")
propose_claim(store, text="t", evidence=[src.id], proposed_by="a", session_id=sess.id)
propose_claim(store, text="u", evidence=[src.id], proposed_by="a", session_id=sess.id)
Expand Down
Loading