fix(sessions): close crystallize review-gate bypass via summary page#77
Conversation
…ouchdev#76) crystallize wrote a durable Page directly through store.put_page, embedding sess.task, sess.note, and sess.agent verbatim into the rendered markdown body. The body was never gated by propose_page + approve, so an agent calling kb.session_start(task=<payload>) and getting any one claim approved via crystallize could land arbitrary content into pages/. The page surfaces in kb.read_page, kb.list_pages, kb.context, and (once vouchdev#60 is fixed) kb.search. Restrict the summary body to fields the proposing agent cannot influence: session id (server-generated), timestamps (server clock), and the list of approved artifact ids. The agent-controlled fields remain on the Session model itself and are still queryable, but no longer promoted into a durable Page. Also include summary_page_id in the session.crystallize audit event's object_ids when a page is written, so vouch audit truthfully attributes the write. Adds two regression tests: - test_crystallize_summary_page_does_not_leak_agent_controlled_fields - test_crystallize_audit_event_records_summary_page_id
📝 WalkthroughWalkthroughCloses a review-gate bypass: sessions.crystallize no longer includes agent-controlled fields (task, note, agent) in durable session-summary pages; summary bodies are server-controlled only. The session.crystallize audit event now includes the written summary page id in object_ids. ChangesSession crystallize review-gate bypass closure
sequenceDiagram
participant crystallize
participant _build_summary_body
participant store
participant audit
crystallize->>_build_summary_body: construct summary body from session_id and approved_artifact_ids
_build_summary_body-->>crystallize: return body (session id, timestamps, artifact ids only)
crystallize->>store: put_page(body)
store-->>crystallize: summary_page_id
crystallize->>crystallize: append summary_page_id to crystallize_object_ids
crystallize->>audit: log_event(name="session.crystallize", object_ids=crystallize_object_ids)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
Fix MR conflict |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with 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.
Inline comments:
In `@tests/test_sessions.py`:
- Line 7: Remove the unused import causing Ruff F401 by deleting "patch" from
the import statement (i.e., remove or change the line "from unittest.mock import
patch") in tests/test_sessions.py so the file no longer imports the unused
symbol; if other mocks are needed, import only the actually used names from
unittest.mock instead of patch.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 198ebfe2-fc0b-45c3-8791-23b02d05b5b1
📒 Files selected for processing (1)
tests/test_sessions.py
Leftover from the merge of main (which brought PR vouchdev#62's test_sessions.py changes); ruff F401 was failing CI on PR vouchdev#77.
Resolves conflicts in CHANGELOG.md, tests/test_sessions.py, and auto-merges src/vouch/sessions.py with the recently-merged: - vouchdev#61 (FTS5 indexing of crystallize summary page) — adds index_db.index_page(...) right after store.put_page(...) inside crystallize. Composes cleanly with this PR's strict-derivation _build_summary_body and the summary_page_id audit fix. - vouchdev#62 (test_crystallize_collects_approval_failures) — preserved as a sibling test. - vouchdev#75 (bundle import sha256 verification) — CHANGELOG entry only. - vouchdev#73 (bundle POSIX separators) — CHANGELOG entry only. Both regression tests added by this PR (test_crystallize_summary_page_does_not_leak_agent_controlled_fields and test_crystallize_audit_event_records_summary_page_id) still pass against the merged sessions.crystallize.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/test_sessions.py (1)
132-135: 💤 Low valueConsider refactoring to avoid double JSON parsing.
Each line is parsed twice: once in the
ifcondition and again for the list comprehension. This is a minor inefficiency.♻️ Proposed refactor using walrus operator
- cryst_events = [ - json.loads(line) for line in audit_lines - if json.loads(line).get("event") == "session.crystallize" - ] + cryst_events = [ + evt for line in audit_lines + if (evt := json.loads(line)).get("event") == "session.crystallize" + ]Alternatively, use a traditional loop:
- cryst_events = [ - json.loads(line) for line in audit_lines - if json.loads(line).get("event") == "session.crystallize" - ] + cryst_events = [] + for line in audit_lines: + evt = json.loads(line) + if evt.get("event") == "session.crystallize": + cryst_events.append(evt)🤖 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 `@tests/test_sessions.py` around lines 132 - 135, The list comprehension parses each JSON line twice; change it to parse once by storing the parsed object and filtering on its "event" field—e.g., use a single json.loads call per line (via the walrus operator or a simple for-loop) so that cryst_events is built from the already-parsed dicts; update the expression around audit_lines and json.loads to reuse the parsed object instead of calling json.loads twice.
🤖 Prompt for all review comments with 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.
Nitpick comments:
In `@tests/test_sessions.py`:
- Around line 132-135: The list comprehension parses each JSON line twice;
change it to parse once by storing the parsed object and filtering on its
"event" field—e.g., use a single json.loads call per line (via the walrus
operator or a simple for-loop) so that cryst_events is built from the
already-parsed dicts; update the expression around audit_lines and json.loads to
reuse the parsed object instead of calling json.loads twice.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ec2ffbbc-1165-4e97-a3f2-6f9aed416a3d
📒 Files selected for processing (3)
CHANGELOG.mdsrc/vouch/sessions.pytests/test_sessions.py
✅ Files skipped from review due to trivial changes (1)
- CHANGELOG.md
|
PR should made against test branch |
|
@plind-junior — retargeted to |
Problem
sessions.crystallizewrites a durablePageartifact directly viastore.put_page(page)(src/vouch/sessions.py:99-110), bypassingthe
propose_page+approvereview gate that guards every otherwrite to
pages/. The page body is built by_build_summary_body(
src/vouch/sessions.py:125-139) and embeds three agent-controlledfields verbatim into rendered markdown:
sess.task— set by the agent atsession_start(task=...)sess.note— set/extended by the agent atsession_start/session_endsess.agent— taken from the agent'sVOUCH_AGENTenv varThere is no escape, no sanitisation, and no review step. Result:
any agent that opens a session and gets one trivial claim approved
via crystallize can land arbitrary markdown into
pages/session-<id>.md— the page is committed to git, returned bykb.read_page/kb.list_pages/kb.context, and (once #60 isfixed) surfaces in
kb.search. The approver reviews the claim,not the session task; their approval action causes a page with
unreviewed agent-chosen content to land.
Secondary audit gap: the
session.crystallizeaudit event atsrc/vouch/sessions.py:112-116lists[sess.id, *approved_artifact_ids]as
object_ids— the new page id is never in the list. Sovouch auditcannot attribute the page write, defeating theaudit-truthfulness guarantee.
This breaks vouch's #1 stated guarantee (README §"Why this exists"
point 2, CONTRIBUTING §"Things we won't merge" point 1):
"Writes require approval. Agents file proposals; a human (or
trusted approving agent) explicitly accepts them."
Fix
Two changes in
src/vouch/sessions.py:_build_summary_bodyis restricted to fields the proposing agentcannot influence: session id (server-generated),
started_atandended_at(set from the server clock atsession_start/session_end), and the list of crystallized artifact ids(each of which did go through the review gate). The
agent-controlled fields (
sess.task,sess.note,sess.agent)are no longer promoted into the durable Page body — they remain
on the
Sessionmodel and are still queryable throughstore.get_session, but no longer surface inkb.read_page/kb.search/kb.context.The
session.crystallizeaudit event now includessummary_page_idinobject_idswhenever a page is written, sovouch audittruthfully attributes every artifact the callproduced.
This is the strict-derivation fix (option (a) in the issue's
"Suggested fix" section); the alternative of routing the page
through
propose_page+approvewould materially changecrystallize's return shape and is left for a separate VEP.
No on-disk-layout, schema, or bundle-format change. Existing
sessions are unaffected: only newly-crystallized session-summary
pages take the new body shape.
Tests
Added two regression tests in
tests/test_sessions.py:test_crystallize_summary_page_does_not_leak_agent_controlled_fields— calls
session_start(task=<markdown payload>, note=<more>)withagent
mallory, files a legitimate claim, runscrystallize,and asserts the resulting page body contains none of the
agent-supplied strings (
## DECISION, the injected note,mallory).test_crystallize_audit_event_records_summary_page_id— readsaudit.log.jsonl, finds the most recentsession.crystallizeevent, and asserts
summary_page_idis in itsobject_ids.All 4 session tests pass on Windows (Python 3.12.13). Full suite
shows the same pre-existing failures present on
main(#72 bundle path separator on Windows;
O_NOFOLLOWuse instorage.py) — both untouched by this PR. Lint clean.Fixes #76
Summary by CodeRabbit
Bug Fixes
Documentation
Tests