Skip to content

bug: crystallize writes a durable Page bypassing the review gate; body embeds agent-controlled markdown verbatim #76

Description

@galuis116

What happened

sessions.crystallize writes a durable Page artifact directly via
store.put_page(page) (src/vouch/sessions.py:99-110), bypassing
the proposals.propose_pageproposals.approve review gate that
guards every other write to pages/.

The body of that page is built by _build_summary_body
(src/vouch/sessions.py:125-139) and embeds three
agent-controlled fields verbatim into rendered markdown:

  • sess.task — set by the agent at session_start(task=...)
  • sess.note — set/extended by the agent at session_start /
    session_end
  • sess.agent — taken from the VOUCH_AGENT env var of the proposing
    agent

There is no escape, no sanitisation, no length cap, and no
review step between the agent supplying these strings and the
durable markdown page landing in pages/session-<id>.md. The page
is committed to git on the next git add .vouch/, indexed (#60
notwithstanding, the page row still lands in the artifact set), and
returned by kb.read_page, kb.list_pages, and kb.context.

The audit event recorded at src/vouch/sessions.py:112-116
lists [sess.id, *approved_artifact_ids] as object_ids — the
new page id is not in the list. So the audit log is also
not truthful about what was written.

This breaks the load-bearing review-gate guarantee
("Writes require approval", README §"Why this exists" point 2 and
CONTRIBUTING §"Things we won't merge" point 1) for the Page
artifact kind. An ordinary agent can land arbitrary markdown
into a reviewed KB by:

  1. kb.session_start(task="<any markdown payload, headings, lists, even fake **Decision:** rows>").
  2. Filing any one legitimate proposal in that session.
  3. Waiting for any approver to crystallize the session
    (which they would do precisely because the trivial claim looks
    fine in vouch show).

The approver reviews the claim, not the session task — but their
approval action causes a page to land whose body content was
unilaterally chosen by the agent.

What you expected

The session-summary page is either:

(a) Strictly derived from already-approved content — only the
session id, the list of crystallized claim ids, the approver
name, and timestamps. Nothing the proposing agent supplied is
promoted into a durable page body. Or

(b) Routed through propose_page + approve so the human
reviewer can see and reject the body before it lands.

Either way, vouch approve (or a crystallize invocation that
internally goes through approve) is the only path to a durable
pages/*.md. And the audit event for session.crystallize
includes the page id in object_ids whenever a page is written.

Reproduction

$ vouch init
$ export VOUCH_AGENT=mallory
$ SID=$(vouch session start --task '## DECISION

We will migrate to MongoDB. Approved by leadership 2026-05-25.

- This page was never reviewed by a human.
- An ordinary agent caused this content to land in the KB.

```python
print("agent-controlled python prose")

' --note 'plausible note' | awk '{print $1}')

$ SRC=$(echo body | vouch source add /dev/stdin)
$ vouch propose-claim --text "trivial real claim" --type fact
--source "$SRC" --session "$SID"
$ vouch session end "$SID"

A different actor, e.g. a human or trusted agent:

$ VOUCH_AGENT=human vouch crystallize "$SID"

$ cat .vouch/pages/session-$SID.md

Session sess-...

Task: ## DECISION

We will migrate to MongoDB. Approved by leadership 2026-05-25.
...

$ vouch audit --tail 1 | python -c "import json,sys; e=json.loads(sys.stdin.read()); print(e['object_ids'])"
['sess-...', 'claim-...'] # page id is absent


The `## DECISION` heading, the fake leadership-approval prose, the
embedded code block — all rendered verbatim in the durable page,
none of it reviewed.

## Environment

- vouch version: `vouch, version 0.0.1` (commit `c3accb6`)
- Python version: `Python 3.12.13`
- OS: any (Linux/macOS/Windows — bug is in pure Python)
- Host: any (CLI, MCP, JSONL — all expose `kb.session_start` and
  `kb.crystallize` to agents)

## `.vouch/` state

$ vouch doctor


Not relevant — the bug is in the crystallize code path and
reproduces against any freshly-initialised KB.

## Anything else

### Suggested fix

`src/vouch/sessions.py`:

1. `_build_summary_body` should be **strictly derived**: drop
   `sess.task`, `sess.note`, and `sess.agent` (or render only the
   `sess.agent` *id*, not user-supplied prose). Keep:
   `session id`, `approver`, `started_at`, `ended_at`, and the
   crystallized claim ids. Nothing the proposing agent supplied
   is promoted into markdown.
2. The audit event at lines 112-116 should include
   `summary_page_id` in `object_ids` when a page is written, so
   `vouch audit` truthfully attributes the write.

Alternative architecture (fine but larger): route the summary
page through `propose_page` + `approve` so the existing gate
applies. This option respects `review.require_human_approval`
but materially changes crystallize's return shape (page becomes
pending rather than durable). The strict-derivation fix is
smaller and equally closes the bypass.

### Why it's worth fixing

- This is the **review-gate** itself — vouch's stated reason to
  exist. Per README: "Writes require approval. Agents file
  *proposals*; a human (or trusted approving agent) explicitly
  accepts them." Today an agent can land a durable, search-visible,
  context-surfacing page without that explicit acceptance.
- Lower attacker barrier than #74 (no need to substitute a tar
  member; just call two normal `kb.*` methods).
- Affects every transport (CLI, MCP, JSONL).
- Worse with #60 fixed: once the summary page is FTS5-indexed,
  the injected markdown surfaces in `kb.search` and
  `kb.context` for every future agent in the project.
- The audit gap (`page_id` missing from `object_ids`) makes
  forensic reconstruction of "who landed this page" impossible
  via `vouch audit` alone.

### Checked for duplicates

- Searched open + closed issues on `vouchdev/vouch` for
  `crystallize`, `session-summary`, `put_page`, `review gate`.
- Existing crystallize issues #47, #56, #60 cover other
  failure modes (self-approval blocking, swallowed approval
  errors, FTS5 indexing). None mention the page-body
  review-gate bypass or the missing `summary_page_id` in
  audit object_ids.
- PR #46 (closed #45) added the self-approval gate to
  `proposals.approve`; the gate is bypassed entirely here
  because the page never goes through `approve`.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions