Skip to content

Fix/artifact id path traversal#150

Closed
philluiz2323 wants to merge 79 commits into
vouchdev:mainfrom
philluiz2323:fix/artifact-id-path-traversal
Closed

Fix/artifact id path traversal#150
philluiz2323 wants to merge 79 commits into
vouchdev:mainfrom
philluiz2323:fix/artifact-id-path-traversal

Conversation

@philluiz2323

@philluiz2323 philluiz2323 commented Jun 2, 2026

Copy link
Copy Markdown

What changed

Adds a shared _validate_artifact_id helper in src/vouch/models.py and wires it as an id @field_validator on Claim, Page, Entity, Relation, Evidence, Session, and Proposal — rejecting empty/whitespace ids, path separators (/, \), .., NUL bytes, ASCII control characters, and leading .. Source keeps its stricter hex-sha256 validator (a content-addressed superset of this rule). On the storage layer, adds KBStore._safe_artifact_path(sub, obj_id, suffix) in src/vouch/storage.py as belt-and-suspenders: every _<X>_path helper (_yaml, _claim_path, _page_path, _source_dir, _entity_path, _relation_path, _evidence_path, _session_path) now resolves the constructed path and verifies it stays inside its artifact subdirectory.

Fixes #149

Why

KBStore._yaml(sub, obj_id) turned the artifact id field straight into a filesystem path (kb_dir/<sub>/{id}.{yaml,md}) with no containment check, and only Source.id was schema-validated. So a bare Claim(id="../escape") plus put_claim writes a YAML one level above kb_dir/claims/; with enough .. segments the write lands outside the project tree entirely. Three paths reach this:

  • Direct constructionClaim(id="../escape", ...) is accepted by Pydantic, then put_claim's only existence check is on claim.evidence, never on claim.id.
  • Bundle importbundle._safe_member_path (CVE-2007-4559 / Path Traversal in Bundle Import — Arbitrary File Write (Critical) #9) sanitises tar member file paths, but the in-YAML id field is independent and was never gated. _validate_content runs Claim.model_validate(...), which (before this change) accepted any id string. A manifest-consistent bundle could ship a poisoned id at a safe tar path; subsequent update_claim / lifecycle ops would resolve the in-memory id straight to an off-tree write target.
  • Raw API callerkb.archive / kb.supersede / kb.contradict (MCP and JSONL surfaces) accept a claim_id and call store.get_claim(claim_id)store.update_claim(claim). An untrusted id propagates into both the read and the write side.

Same structural shape as #81 (claim-citation validator), #123 (graph integrity), and #125 (source content-addressing): an invariant the project already enforces at one boundary (here: bundle._unsafe_name_reason for tar member names) extended to the parallel surface (the in-YAML id field). The fix is the smallest one that closes all three reach paths simultaneously.

What might break

Nothing on-disk, schema, bundle-format, MCP/JSONL surface, or audit-log shape — strictly additive validation. Honest slug-style ids (what proposals._slugify produces, e.g. auth-uses-jwt) are unaffected; only data the documented containment contract already says shouldn't exist starts being rejected at write time.

VEP

Not required. No change to the object model fields, kb.* method surface, on-disk layout, bundle format, or audit-log shape — this only tightens write-time validation to match a containment contract bundle._unsafe_name_reason and read_under_root already document.

Tests

  • make check passes locally — ruff check src tests clean, mypy src reports Success: no issues found in 32 source files, full pytest green on Windows / Python 3.13 except the pre-existing tests/test_jsonl_server.py::test_read_under_root_rejects_symlink_at_resolved_path which needs symlink-create privilege (unrelated; same env-only skip noted in fix(models): require Claim.evidence to be non-empty at the model layer #82 / fix: reject dangling Relation/Page references at every write path #124 / fix: enforce Source content-addressing on bundle/sync import #126).

  • New / changed behaviour has tests — 13 new regressions:

    • tests/test_storage.py: per-model traversal-id rejection (Claim, Page, Entity, Relation, Evidence), NUL-byte rejection, leading-dot rejection, empty/whitespace rejection, put_claim doesn't write off-tree, _<X>_path storage-layer guard for every artifact kind, update_claim rejection on in-place mutation to a traversal id.
    • tests/test_bundle.py: bundle whose claim YAML has id: "../escape" is rejected by import_check (schema validation failed: …path separators…) and import_apply raises; no off-tree file appears.
    • tests/test_health.py: legacy KB with a traversal-id claim YAML on disk surfaces as an invalid_claim finding via vouch lint rather than crashing with a bare pydantic.ValidationError — mirrors test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing for the bug: Claim model has no min-evidence validator — uncited claims land via bundle import, put_claim, update_claim #81 / fix(models): require Claim.evidence to be non-empty at the model layer #82 migration.
  • CHANGELOG.md updated under ## [Unreleased]add before merge (suggested entry):

    - `Claim`, `Page`, `Entity`, `Relation`, `Evidence`, `Session`, and `Proposal`
      now reject ids containing path separators, `..`, NUL bytes, control characters,
      or a leading `.` at the model layer via `_validate_artifact_id`. `KBStore`
      additionally re-checks every artifact path stays inside its subdirectory via
      `_safe_artifact_path`. Closes the arbitrary-file-write vector where a bare
      `Claim(id="../escape")` + `put_claim` (or a bundle YAML with the same poisoned
      id field, reaching `update_claim` / lifecycle ops later) would write outside
      `kb_dir/<sub>/`. Distinct from the tar-member-path guard from #9 / CVE-2007-4559
      — the in-YAML id field is the orthogonal surface this closes. `Source.id`'s
      existing hex-sha256 validator is unchanged.
    

Summary by CodeRabbit

Release Notes

  • New Features

    • Added vouch fsck for deeper read-only consistency checks
    • Added vouch expire to remove stale pending proposals
    • Added vouch init --template with Gittensor starter template option
    • Added vouch review interactive workflow for proposal approval/rejection
    • Added vouch sync-check/vouch sync-apply for distributed knowledge base synchronization
    • Added vouch diff command to compare artifact revisions
    • Batch approval support: vouch approve now accepts multiple IDs with --keep-going mode
    • Added --json output for lint, search, pending, and diff commands
    • Added structured JSON logging via VOUCH_LOG_FORMAT=json
  • Bug Fixes

    • Claims now enforce at least one citation
    • Self-approval prevention enforced
    • Improved error messaging (cleaner, no raw tracebacks)
  • Documentation

    • Added guides for Gittensor integration and distributed sync workflows
    • Expanded retrieval backend documentation

galuis116 and others added 30 commits May 25, 2026 04:28
…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
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.
…index FTS5 on update (vouchdev#78)

Two compounding bugs let archived / superseded / redacted claims keep
flowing back to agents through kb.context:

  1. build_context_pack had no claim.status filter, so any retrieval
     hit was appended to the pack regardless of subsequent lifecycle
     mutations.

  2. store.update_claim refreshed the embedding cache but never
     re-indexed the FTS5 row, so claims_fts.status stayed frozen at
     first-index time. Every lifecycle op (archive, supersede,
     contradict, confirm) routes through update_claim and was
     therefore invisible to FTS5 / semantic search.

This made ClaimStatus.{ARCHIVED, SUPERSEDED, REDACTED} purely
decorative on the read side — agents kept quoting retracted
knowledge as if it were live.

Fix:

  - storage.update_claim now also calls index_db.index_claim under a
    short-lived sqlite connection (mirroring proposals.approve's
    first-index pattern). FTS5 errors are logged-and-skipped so a
    lifecycle op never fails because the embedding/FTS5 layer is
    misconfigured.

  - context.build_context_pack resolves each claim hit, drops it if
    the on-disk status is in {ARCHIVED, SUPERSEDED, REDACTED}, and
    drops it if the claim YAML has been deleted between index time
    and now. CONTESTED claims keep surfacing so contradictions
    remain visible.

Tests:

  - test_context_pack_excludes_archived_claims
  - test_context_pack_excludes_superseded_claims
  - test_update_claim_refreshes_fts5_status (asserts the FTS5 status
    column via direct SQL against state.db)

No on-disk-layout, schema, or bundle-format change.
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.
vouchdev#81)

The 'claims must cite sources' guarantee (README §'Why this exists'
point 3; CONTRIBUTING §'Things we won't merge') used to live only in
proposals.propose_claim, so every other write path silently accepted
Claim(evidence=[]) and landed an uncited claim:

  - store.put_claim direct: existence-check loop iterates zero times.
  - store.update_claim: writes the YAML without re-validating.
  - bundle.import_apply via _validate_content: defers to
    Claim.model_validate, which accepted evidence=[] because the
    model had no min-length constraint.

Add @field_validator('evidence') on Claim — raises ValueError when
the list is empty. Closes all three bypass paths in one place.

store.update_claim additionally re-validates via
Claim.model_validate(claim.model_dump()) before persisting, so
in-place mutation (c.evidence = []; store.update_claim(c)) raises
before the YAML hits disk — the field validator only fires at
construction time, not on attribute assignment.

Four regression tests:

  - test_claim_model_rejects_empty_evidence (tests/test_storage.py)
    — Claim(evidence=[]) raises pydantic.ValidationError.
  - test_put_claim_rejects_empty_evidence — store.put_claim raises;
    no claims/<id>.yaml is written.
  - test_update_claim_rejects_empty_evidence — in-place mutation
    + update_claim raises; the on-disk YAML is unchanged.
  - test_import_rejects_uncited_claim (tests/test_bundle.py) — a
    schema-valid bundle whose claim YAML has evidence: [] is
    rejected by import_check (schema validation issue) and
    import_apply raises before writing.

The existing guard in proposals.propose_claim becomes a redundant
user-facing error message and is left in place for the friendlier
CLI/JSONL error string. No on-disk-layout, schema, or
bundle-format change; data the model never should have accepted
now raises.
…ing from tarball (vouchdev#80)

import_check previously only verified that tar members listed in the
manifest had matching hashes, but never checked the reverse: whether
every manifest entry had a corresponding tar member. A bundle whose
manifest.json referenced claims/c1.yaml but whose tarball contained
only manifest.json would pass import_check with ok=True, and
import_apply would silently write nothing — no exception, no audit
event indicating data loss.

Add the missing-member pass (mirroring the existing check in
export_check) so that manifest entries without a matching tar member
produce a "manifest lists missing file" issue. import_apply then
refuses to import because check.issues is non-empty.
read-only `vouch diff <old> <new>` that shows what changed between two
claim or two page revisions: field-level changes plus a line-diff of the
long text/body. auto-detects kind, hides churning metadata.
new read-only `vouch diff <old> <new>` compares two claims or two pages by
id and shows what changed: scalar/list fields as old → new, plus a line-diff
of the long text/body. auto-detects kind, hides churning metadata
(timestamps, approved_by), supports --json. closes a roadmap 0.1 item.
mypy inferred old/new as Claim from the first branch, so the page branch's
reassignment tripped an incompatible-assignment error in CI. annotate the
union up front.
feat(diff): add `vouch diff` for claim/page revisions
…hdev#82 review)

The new Claim.evidence min-citation validator (vouchdev#81) also fires when
claims are read back from disk. A KB that has a pre-existing
uncited claims/<id>.yaml from before the fix would otherwise crash
vouch lint / vouch doctor with a bare pydantic.ValidationError
deep in store.list_claims().

Add _load_claims_for_lint(), a per-file iteration that catches
pydantic.ValidationError (and any other load error) and surfaces
each bad file as a Finding with code='invalid_claim' and an
explicit repair hint: 'edit the YAML to add a citation, or delete
the file'. lint() also stops calling status() to populate counts —
status() calls the strict store.list_claims() which would re-raise
on the same files — and builds the counts dict inline from the
safely-loaded valid claims.

Regression test in tests/test_health.py:
  - test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing
    hand-crafts a claims/legacy.yaml with evidence: []  (matches
    the on-disk shape an older buggy write path would have left),
    asserts vouch lint runs to completion, surfaces invalid_claim
    in findings with the repair-hint message, and that the
    well-formed sibling claim is still discovered.

CHANGELOG migration note expanded to describe the repair hint.
The new inline-built counts in lint() (from 5c881de) is a literal
dict with mixed value types (str/int/bool), which mypy correctly
inferred as dict[str, object] and rejected against the
HealthReport.counts: dict[str, int] annotation. The original
status() returned the same mixed dict via an untyped 'dict' return,
which masked the mismatch — the narrow type was effectively never
checked at the call site.

Widen counts to dict[str, Any] to match runtime reality. Also
tighten status()'s return annotation from 'dict' to 'dict[str, Any]'
for consistency. No caller does arithmetic on counts values; they
just echo or pass through, so the widening is risk-free.
…ummary-page-bypass

fix(sessions): close crystallize review-gate bypass via summary page
…s-citation

fix(models): require Claim.evidence to be non-empty at the model layer
…nd-config

fix(context): honor retrieval.backend config instead of hardcoding embeddings
feat(cli): vouch approve accepts multiple ids for scriptable bulk approval
jsdevninja and others added 26 commits May 28, 2026 10:20
…ontent-addressing

# Conflicts:
#	CHANGELOG.md
#	tests/test_bundle.py
#	tests/test_cli.py
generalize init seeding into a named-template registry and ship a gittensor
(SN74) starter pack as the first non-default template. complements gittensory
rather than cloning its live-data signals.
generalize init seeding into a small in-code template registry. default
'starter' is unchanged; new 'gittensor' template seeds a cited, approved
pack (1 source, 1 entity, 4 claims) about SN74 contribution scoring so a
fresh kb in a gittensor repo has retrievable context immediately. unknown
--template gives a clean error listing available packs.

complements gittensory's live-data signals rather than cloning them.
# Conflicts:
#	src/vouch/health.py
#	tests/test_cli.py
feat(init): add --template with a Gittensor (SN74) starter pack
…late

feat(init): add --template with a gittensor seed pack
…chdev#97)

Adds a `vouch.logging_config` module with a `JsonFormatter` and an
idempotent `configure_logging()` that the CLI, MCP server, and JSONL
server entry points call at startup.

When `VOUCH_LOG_FORMAT=json`, the `vouch` logger emits one JSON object
per line with `level`, `logger`, `event`, and any structured extras
(`actor`, `object_ids`, ...) passed via the stdlib `extra=` argument.
Any other value (including unset) is a no-op - vouch's loggers keep
stdlib default behaviour, so callers who don't opt in see no change.

Scoped to the `vouch` logger namespace with propagate=False in JSON
mode, so this never reformats logs emitted by libraries vouch happens
to depend on.
…tion-docs

docs(gittensor): adoption guide (init, MCP wiring, decision workflow)
…le-proposals

Feat/vouch expire stale proposals
the var was already documented in adapters/generic-mcp/README ('force a
specific KB root, skip discovery') but never wired into the code. now
`discover_root()` reads VOUCH_KB_PATH first and short-circuits the upward
walk, with a clear error if the value isn't an existing .vouch directory.

closes the doc-vs-code drift and removes the per-host 'cwd: ...' ceremony
needed by Claude Desktop (and any other host that launches the stdio
server from a default working directory).
fix(storage): honour VOUCH_KB_PATH env var in discover_root
Address review on vouchdev#112.

- fsck() now loads claims via _load_claims_for_lint() like lint(), so a
  single invalid YAML (e.g. a legacy uncited claim from before vouchdev#81)
  becomes an `invalid_claim` finding instead of aborting the whole check
  with a traceback — the exact inconsistency a deep checker should
  surface. Counts are built from the safely-loaded claims via a shared
  _safe_counts() helper so the report can't re-trip the strict loader.

- Add _drift_findings(kind, indexed_ids, on_disk_ids, findings) to emit
  the orphan + missing-row findings, collapsing the three near-identical
  blocks in _check_index_drift. Codes and messages are unchanged; adding
  a kind is now a one-liner.

Add test_fsck_surfaces_invalid_claim_yaml_without_crashing.
Replace the _vouch_managed attribute (and its type: ignore[attr-defined])
with a _VouchManagedHandler(StreamHandler) subclass. configure_logging()
identifies its handler by isinstance for install/reuse/remove, which reads
cleaner and drops the attribute hack. Tests updated to match.
feat(health): vouch fsck — deep consistency checks beyond doctor
…t-json

feat: structured JSON logging via VOUCH_LOG_FORMAT=json
…ent-addressing

fix: enforce Source content-addressing on bundle/sync import
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements a major feature release (0.1.0) spanning proposal lifecycle improvements, knowledge-base synchronization, Gittensor template support, structured logging, storage security hardening, and operational enhancements. All changes are tested and documented comprehensively.

Changes

Comprehensive 0.1.0 Release

Layer / File(s) Summary
Security & data integrity hardening
src/vouch/models.py, src/vouch/storage.py, src/vouch/bundle.py, tests/test_storage.py, tests/test_bundle.py
Shared artifact-id validator blocks path traversal in all artifact kinds; evidence must cite at least one source; bundle import validates source content-addressing; safe artifact paths prevent traversal writes; VOUCH_KB_PATH environment override for KB discovery.
Proposal expiry & batch approval workflow
src/vouch/proposals.py, src/vouch/cli.py, src/vouch/server.py, src/vouch/jsonl_server.py, tests/test_expire.py, tests/test_cli.py
Expiry logic collects stale pending proposals by configured threshold; approval gating centralized in check_approvable(); batch vouch approve with all-or-nothing (default) and --keep-going modes; vouch expire --apply and kb.expire MCP/JSONL handlers; one audit event per approved artifact.
Interactive review queue & artifact diffing
src/vouch/cli.py, src/vouch/diff.py, tests/test_diff.py, tests/test_cli.py
New vouch review command prompts per-proposal approve/reject/skip/quit with dry-run support; diff_artifacts compares claims/pages with field-level and unified line-diff output; vouch diff CLI supports text and JSON modes.
Deterministic KB synchronization
src/vouch/sync.py, src/vouch/cli.py, tests/test_sync.py, docs/multi-agent.md
sync_check and sync_apply for merging KB directories/bundles with SHA-256 conflict detection; conflict policies (fail/skip/propose with JSON reports); audit logging; config.yaml excluded from sync.
Init templates & Gittensor KB seeding
src/vouch/onboarding.py, src/vouch/cli.py, docs/gittensor.md, docs/superpowers/specs/2026-05-27-init-templates-design.md, tests/test_templates.py
Template registry for vouch init --template <name>; Gittensor SN74 starter KB with source/entity/four claims; idempotent seeding; available_templates() API; Gittensor workflow docs.
Structured JSON logging infrastructure
src/vouch/logging_config.py, src/vouch/cli.py, src/vouch/server.py, src/vouch/jsonl_server.py, tests/test_logging.py
JsonFormatter emits single-line JSON log records with level/logger/event/extras; configure_logging() installs managed handler based on VOUCH_LOG_FORMAT=json env var (idempotent, toggleable); wired into all entry points.
CLI output improvements
src/vouch/cli.py, tests/test_cli_output.py
Terminal-aware colors (NO_COLOR/FORCE_COLOR), progress callbacks for index/export/import, --json output for health/search commands, centralized findings/proposal rendering with severity styling.
Retrieval backend configuration
src/vouch/context.py, src/vouch/storage.py, tests/test_retrieval_backend.py, docs/ROADMAP.md, README.md
Config-driven backend selection via retrieval.backend (auto/embedding/fts5/substring) with fallback to legacy retrieval.backends; backend-labeled search hits; retracted claim filtering in context packs; FTS5 re-indexing on claim update.
Health & consistency verification
src/vouch/health.py, tests/test_health.py
New fsck() for deep consistency checks (lifecycle chains, decided artifacts, index/FTS drift, orphans); lint safely loads invalid claim YAML as findings; doctor/rebuild_index accept progress callbacks.
Session summary page safety
src/vouch/sessions.py, tests/test_sessions.py
crystallize includes summary_page_id in audit event; _build_summary_body excludes agent-controlled fields to prevent injection.
Documentation & changelog
CHANGELOG.md, README.md, ROADMAP.md, docs/PROJECT-INDEX.md, docs/gittensor.md, docs/multi-agent.md, docs/review-gate.md, docs/superpowers/specs/\*
Comprehensive release notes (Unreleased + 0.1.0 entries), architecture index, Gittensor integration guide, sync workflow, template spec, diff spec, updated approval/retrieval docs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

  • vouchdev/vouch#149: The main changes directly fix the reported validation/containment gap by adding a shared artifact-id validator in src/vouch/models.py and a safe artifact-path check in src/vouch/storage.py, addressing path-traversal writes.
  • vouchdev/vouch#132: Implements the proposal to seed a Gittensor repo's .vouch/ KB via the new vouch init --template gittensor feature with onboarding docs.

Possibly related PRs

  • vouchdev/vouch#136: The proposal-expiry feature in this PR directly includes/extends the same expire_pending logic, vouch expire CLI, and kb.expire JSONL/MCP handlers.
  • vouchdev/vouch#82: Both PRs implement the same model-layer Claim.evidence non-empty enforcement and vouch lint findings behavior for legacy uncited claims.
  • vouchdev/vouch#36: This PR's retrieval backend dispatch (retrieval.backend config modes, backend-labeled search) builds directly on PR #36's embedding backend work.

Suggested reviewers

  • plind-junior

Poem

A rabbit hops with joy so bright, 🐰
New templates bloom, KB's take flight,
Sync spreads wisdom far and wide,
Expiry cleans, approval guides,
Logs flow clear as crystal streams,
Safety guards the vouch KB dreams! ✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
CHANGELOG.md (1)

66-152: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add the artifact-id traversal hardening to the 0.1.0 release notes.

This block documents tar-member traversal and bundle integrity, but it still omits the model/storage guard that blocks Claim(id="../escape")-style writes and the legacy poisoned-id lint behavior called out in the PR objective. That leaves the main security fix in this cohort undocumented for upgraders.

🤖 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` around lines 66 - 152, The changelog omits the artifact-id
traversal/storage hardening that prevents writes with poisoned ids (e.g.,
Claim(id="../escape")) and the legacy poisoned-id lint behavior; update the
0.1.0 release notes to mention the model/storage guard (Claim validation that
rejects path-traversal ids), the store-side checks in store.put_claim /
store.update_claim that enforce safe ids, and the vouch lint change that reports
poisoned-id files rather than crashing (describe behavior of the new lint
finding and migration guidance for affected KBs).
src/vouch/bundle.py (1)

416-420: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail the import on write-time validation drift.

import_check() now rejects bad source content-addressing, but import_apply() only reruns _validate_content() here, skips invalid members, and still records a successful import. That leaves a TOCTOU hole: a bundle changed between the check pass and this second tar read can bypass the new source invariant or silently drop tampered members while the audit log still says bundle.import succeeded.

Suggested fix
             val_issues: list[str] = []
             _validate_content(member.name, body, val_issues)
+            _check_source_content_address(member.name, body, val_issues)
             if val_issues:
-                skipped.append(member.name)
-                continue
+                raise RuntimeError(f"refusing to import: {val_issues[0]}")
🤖 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 `@src/vouch/bundle.py` around lines 416 - 420, import_apply currently ignores
validation failures from _validate_content by appending to skipped and
continuing, which allows TOCTOU tampering to pass; instead, make import_apply
fail the whole import when val_issues is non-empty: remove the
skipped.append(member.name)/continue behavior and raise a clear exception (e.g.,
ValueError or a specific ImportValidationError) including member.name and
val_issues after calling _validate_content, so the caller records the import as
failed (and adjust any surrounding try/except to propagate the error to the
audit/logging path). Ensure the error type and message reference import_apply,
member.name, and val_issues for easy debugging.
src/vouch/context.py (1)

217-223: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter backend/explain from the surviving items, not raw hits.

items now correctly drops archived/superseded/redacted claims, but result["backend"] and especially result["explain"] are still derived from the unfiltered hits. With explain=True, retracted claim ids and scores still leak back to the caller, which bypasses the lifecycle guard added above.

🤖 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 `@src/vouch/context.py` around lines 217 - 223, result["backend"] and
result["explain"] are currently built from raw hits (leaking retracted/archived
items); instead, compute these values from the filtered surviving items. Build a
lookup (e.g., id_to_meta) from hits mapping claim id -> backend and score, then
set result["backend"] using the backend for the first surviving item in items
(or "none" if no items), and build result["explain"] by iterating over items and
pulling kind/id/score/backend from the lookup so only surviving items are
reported; update references to result["backend"] and result["explain"]
accordingly.
🤖 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 `@docs/superpowers/specs/2026-05-25-vouch-diff-design.md`:
- Around line 63-73: The fenced diff example in the docs lacks a language tag
which triggers markdownlint-cli2; update the triple-backtick fence that wraps
the diff block (the example showing "diff claim <old> → <new>" with
status/confidence/evidence/text lines) to include an explicit language
identifier (e.g., use "text" or "console") so the linter recognizes the block
while keeping the content unchanged.

In `@docs/superpowers/specs/2026-05-27-init-templates-design.md`:
- Around line 15-17: The fenced CLI example containing the line `vouch init
[--path P] [--template starter|gittensor]` needs a language tag to satisfy
markdownlint MD040; update the opening fence for that code block (the triple
backticks before the `vouch init` example) to include a language such as bash or
console (e.g., replace ``` with ```bash) so the fenced CLI example is explicitly
labeled.

In `@README.md`:
- Line 278: The README line claiming "multi-agent sync" is not implemented is
now stale; update that sentence to remove or rephrase the "multi-agent sync"
limitation and instead note that multi-agent synchronization is supported via
the new CLI commands `sync-check` and `sync-apply` (and link or reference the
multi-agent guide). Locate the sentence containing "multi-agent sync" in the
Pre-1.0 section and edit it to either drop that limitation or replace it with a
short note pointing readers to the multi-agent guide and the
`sync-check`/`sync-apply` workflow.
- Line 264: The CLI capability row in the README table is missing shipped
commands and must be updated to match the documented CLI surface: modify the CLI
row (the table cell containing `init`, `discover`, `capabilities`, ..., `serve`)
to include the missing commands `review`, `expire`, `sync-check`, `sync-apply`,
and `diff` so the "What ships today" summary reflects the actual shipped
commands; ensure the final comma/spacing formatting matches the existing list
style.

In `@src/vouch/cli.py`:
- Around line 472-499: Detect duplicate proposal ids in proposal_ids before any
check_approvable/do_approve work and reject the CLI invocation if any duplicates
exist: use collections.Counter to find ids with count > 1, emit an error line
for each duplicate (e.g. click.echo(f"✗ {pid}: duplicate proposal id",
err=True)) and raise click.ClickException listing the duplicate ids so the
command exits without mutating state; add this check before the existing
keep_going / check_approvable block (referencing proposal_ids, keep_going,
check_approvable, and do_approve).

In `@src/vouch/context.py`:
- Around line 148-154: The code currently only skips ArtifactNotFoundError from
store.get_claim(hid) but must also treat claim validation/load failures as
non-retrievable; update the try/except around store.get_claim(hid) to also catch
the specific exception(s) raised when a claim file is invalid (e.g.,
ClaimValidationError or ClaimLoadError — or the concrete exception your loader
raises) and continue on those exceptions just like ArtifactNotFoundError,
leaving the subsequent logic that checks claim.status against
_RETRACTED_CLAIM_STATUSES and uses claim.evidence unchanged; add a regression
test (e.g., test_indexed_invalid_claim_skipped) that inserts an
indexed-but-invalid claim YAML causing get_claim to raise and asserts the
context build completes and that the bad HID is ignored.

In `@src/vouch/diff.py`:
- Around line 75-84: The diff_artifacts function currently calls _kind_of per id
and rejects on the first differing kind; instead, implement pairwise kind
resolution: in diff_artifacts (and using KBStore, ArtifactDiff, DiffError) first
check whether both ids are claims (e.g., a helper like _exists_kind(store, id,
"claim") or calling _kind_of but comparing existence) and if so treat
kind="claim"; else check whether both are pages and if so treat kind="page";
only if neither pair matches, raise DiffError(s) that reflect unknown
artifact(s) or mismatched kinds—report unknown for an id only if it truly
doesn't exist in any namespace and otherwise return the appropriate cannot-diff
message when kinds differ. Ensure you update references in diff_artifacts to use
this pairwise resolution logic rather than per-id early returns.

In `@src/vouch/health.py`:
- Around line 84-114: The current safe-load pattern in _load_claims_for_lint
only protects Claim model loading; extend this to Page, Entity, Relation,
Evidence, Session, and Proposal by extracting the try/except per-file logic into
a reusable helper (or replicate the pattern) that iterates files under
store.kb_dir / "<kind>s", calls model.model_validate(_yaml_load(...)) for each,
appends valid models to the appropriate list, and converts ValidationError into
a Finding with code "invalid_<kind>" and other exceptions into
"unreadable_<kind>"; update the lint()/fsck() callers (and the loading blocks
currently for pages/entities/relations/evidence/sessions/proposals) to use this
safe loader so a single invalid file yields a Finding instead of aborting the
sweep.

In `@src/vouch/logging_config.py`:
- Around line 119-126: The JSON logging branch currently adds a handler but
leaves the vouch logger at WARNING so INFO records are still filtered; update
the JSON branch in logging_config.py (the block where selected == "json"
handling occurs, referencing logger and _VouchManagedHandler/JsonFormatter) to
explicitly lower the logger level to include INFO (e.g., set
logger.setLevel(logging.INFO) or logger.setLevel(os.environ-configurable level)
and ensure the handler's level permits those records) so vouch.info() messages
are emitted to the JSON handler.

In `@src/vouch/proposals.py`:
- Around line 371-387: The function expire_pending_after_days currently treats
booleans as integers because isinstance(..., int) accepts bool; update the
checks to reject booleans by using exact type checks: in the override branch
ensure type(override) is int and override >= 0 before returning it (otherwise
fall through to config/default), and replace isinstance(days, int) with
type(days) is int and days >= 0; keep returning _DEFAULT_EXPIRE_PENDING_DAYS for
all other cases. Reference: expire_pending_after_days, KBStore,
_DEFAULT_EXPIRE_PENDING_DAYS.

In `@src/vouch/server.py`:
- Around line 431-432: kb_expire() currently calls expire_pending(...,
expired_by=EXPIRE_ACTOR) which overwrites the transport-supplied caller (e.g.,
VOUCH_AGENT) so downstream lifecycle/audit fields lose operator identity; change
kb_expire() to pass the actual incoming caller/actor into expire_pending via the
expired_by parameter (use the actor value supplied to kb_expire() or the
transport context) instead of EXPIRE_ACTOR so the original caller is preserved
through expire_pending and lifecycle/audit.

In `@src/vouch/storage.py`:
- Around line 370-399: update_claim currently revalidates Claim shape but omits
the citation/source existence checks that put_claim performs, so mutated
claim.evidence can reference unknown artifacts; before writing and storing,
replicate the same existence validation loop used in put_claim (or extract it
into a shared helper) to verify each evidence/source id exists and raise
ArtifactNotFoundError for any missing id, then proceed with persistence,
_embed_and_store and FTS5 reindexing as before.

In `@src/vouch/sync.py`:
- Around line 148-154: _in _load_bundle_source, wrap the tarfile.open(...) call
in a try/except that catches tarfile.ReadError and re-raises a normalized error
(e.g., raise RuntimeError(f"invalid bundle file: {source_path}") from exc) so
invalid/regular files produce the same user-facing sync error the CLI expects;
keep the existing manifest lookup and JSON parsing logic intact and preserve the
original exception as the __cause__ when re-raising.

In `@tests/test_cli_output.py`:
- Around line 30-56: The tests test_status_no_color_by_default_in_pipe,
test_status_force_color_emits_ansi, and test_no_color_wins_over_force_color call
CliRunner().invoke(cli, ["status"]) which defaults to color=False and can strip
ANSI sequences; update each invocation to pass color=True (i.e.,
CliRunner().invoke(cli, ["status"], color=True) ) so the result.output preserves
ANSI escape sequences and the "\x1b[" assertions reliably test
FORCE_COLOR/NO_COLOR precedence.

In `@tests/test_templates.py`:
- Around line 78-82: Update test_init_unknown_template_clean_error to assert the
CLI error includes the list of valid template names in addition to the current
checks: after invoking cli in that test, import or call the source of truth for
templates (e.g. AVAILABLE_TEMPLATES constant or get_available_template_names()
used by cli) and assert that each expected template name (or the joined list
string) appears in res.output (case-insensitive) so the failure message shows
the available-template guidance.

---

Outside diff comments:
In `@CHANGELOG.md`:
- Around line 66-152: The changelog omits the artifact-id traversal/storage
hardening that prevents writes with poisoned ids (e.g., Claim(id="../escape"))
and the legacy poisoned-id lint behavior; update the 0.1.0 release notes to
mention the model/storage guard (Claim validation that rejects path-traversal
ids), the store-side checks in store.put_claim / store.update_claim that enforce
safe ids, and the vouch lint change that reports poisoned-id files rather than
crashing (describe behavior of the new lint finding and migration guidance for
affected KBs).

In `@src/vouch/bundle.py`:
- Around line 416-420: import_apply currently ignores validation failures from
_validate_content by appending to skipped and continuing, which allows TOCTOU
tampering to pass; instead, make import_apply fail the whole import when
val_issues is non-empty: remove the skipped.append(member.name)/continue
behavior and raise a clear exception (e.g., ValueError or a specific
ImportValidationError) including member.name and val_issues after calling
_validate_content, so the caller records the import as failed (and adjust any
surrounding try/except to propagate the error to the audit/logging path). Ensure
the error type and message reference import_apply, member.name, and val_issues
for easy debugging.

In `@src/vouch/context.py`:
- Around line 217-223: result["backend"] and result["explain"] are currently
built from raw hits (leaking retracted/archived items); instead, compute these
values from the filtered surviving items. Build a lookup (e.g., id_to_meta) from
hits mapping claim id -> backend and score, then set result["backend"] using the
backend for the first surviving item in items (or "none" if no items), and build
result["explain"] by iterating over items and pulling kind/id/score/backend from
the lookup so only surviving items are reported; update references to
result["backend"] and result["explain"] accordingly.
🪄 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: d6fd9250-c621-4e6e-85fc-b7072d377c34

📥 Commits

Reviewing files that changed from the base of the PR and between 3beb821 and 8311441.

📒 Files selected for processing (38)
  • CHANGELOG.md
  • README.md
  • ROADMAP.md
  • docs/PROJECT-INDEX.md
  • docs/README.md
  • docs/gittensor.md
  • docs/multi-agent.md
  • docs/review-gate.md
  • docs/superpowers/specs/2026-05-25-vouch-diff-design.md
  • docs/superpowers/specs/2026-05-27-init-templates-design.md
  • src/vouch/bundle.py
  • src/vouch/capabilities.py
  • src/vouch/cli.py
  • src/vouch/context.py
  • src/vouch/diff.py
  • src/vouch/health.py
  • src/vouch/jsonl_server.py
  • src/vouch/logging_config.py
  • src/vouch/models.py
  • src/vouch/onboarding.py
  • src/vouch/proposals.py
  • src/vouch/server.py
  • src/vouch/sessions.py
  • src/vouch/storage.py
  • src/vouch/sync.py
  • tests/test_bundle.py
  • tests/test_cli.py
  • tests/test_cli_output.py
  • tests/test_context.py
  • tests/test_diff.py
  • tests/test_expire.py
  • tests/test_health.py
  • tests/test_logging.py
  • tests/test_retrieval_backend.py
  • tests/test_sessions.py
  • tests/test_storage.py
  • tests/test_sync.py
  • tests/test_templates.py

Comment on lines +63 to +73
```
diff claim <old> → <new>
status: working → stable
confidence: 0.7 → 0.9
evidence: ['s1'] → ['s1', 's2']
text:
--- a
+++ b
-old wording
+new wording
```

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

Add a language tag to this fenced example.

markdownlint-cli2 flags this block because the fence has no language. text or console would satisfy the lint rule without changing the content.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 63-63: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@docs/superpowers/specs/2026-05-25-vouch-diff-design.md` around lines 63 - 73,
The fenced diff example in the docs lacks a language tag which triggers
markdownlint-cli2; update the triple-backtick fence that wraps the diff block
(the example showing "diff claim <old> → <new>" with
status/confidence/evidence/text lines) to include an explicit language
identifier (e.g., use "text" or "console") so the linter recognizes the block
while keeping the content unchanged.

Comment on lines +15 to +17
```
vouch init [--path P] [--template starter|gittensor]
```

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

Add a language to the fenced CLI example.

This block triggers the reported markdownlint failure (MD040).

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 15-15: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@docs/superpowers/specs/2026-05-27-init-templates-design.md` around lines 15 -
17, The fenced CLI example containing the line `vouch init [--path P]
[--template starter|gittensor]` needs a language tag to satisfy markdownlint
MD040; update the opening fence for that code block (the triple backticks before
the `vouch init` example) to include a language such as bash or console (e.g.,
replace ``` with ```bash) so the fenced CLI example is explicitly labeled.

Comment on lines +55 to +67
### `seed_gittensor_kb(store, approved_by) -> SeedResult`
Seeds, idempotently and approved-direct:
- **1 Source** — SN74 description text (`title="Gittensor SN74"`,
`locator="vouch:template/gittensor"`, `source_type="message"`,
`media_type="text/markdown"`), content from Gittensor's public README facts.
- **1 Entity** — `gittensor-sn74` (`type=project`).
- **4 Claims** (`type=fact`, `status=stable`, each citing the source, linked to
the `gittensor-sn74` entity):
1. Miners earn TAO for pull requests merged into whitelisted repositories.
2. Validators verify GitHub account ownership via a fine-grained PAT before scoring.
3. Contributions are scored by code quality, repository allocation, and language factors.
4. Sybil-resistant: GitHub account verification + merged-PR requirement prevent gaming.
- Each artifact keyed by a stable id; existence checks make re-runs no-ops.

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

Sync the spec with the shipped template contents.

This doc says the gittensor pack seeds 4 claims, but tests/test_templates.py now expects 7 claims and names additional seeded claim ids. Leaving the design doc behind the tested behavior will mislead the next person touching onboarding or docs.

Also applies to: 86-93

Comment thread README.md
|------|-----------------|
| Knowledge base | `.vouch/` folder, YAML claims/entities/relations/evidence/sessions, markdown pages with frontmatter, JSONL audit log, content-addressed sources |
| CLI | `init`, `discover`, `capabilities`, `status`, `lint`, `doctor`, `pending`, `show`, `approve`, `reject`, `propose-{claim,page,entity,relation}`, `source add`, `source verify`, `supersede`, `contradict`, `archive`, `confirm`, `cite`, `session {start,end}`, `crystallize`, `search`, `context`, `index`, `audit`, `export`, `export-check`, `import-check`, `import-apply`, `serve` |
| CLI | `init`, `discover`, `capabilities`, `status`, `lint`, `doctor`, `fsck`, `pending`, `show`, `approve`, `reject`, `propose-{claim,page,entity,relation}`, `source add`, `source verify`, `supersede`, `contradict`, `archive`, `confirm`, `cite`, `session {start,end}`, `crystallize`, `search`, `context`, `index`, `audit`, `export`, `export-check`, `import-check`, `import-apply`, `serve` |

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

Update the capability table to match the documented CLI surface.

This row is now missing shipped commands that are documented above in the same README, including review, expire, sync-check, sync-apply, and diff. Readers scanning “What ships today” will get an outdated surface summary.

🤖 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 `@README.md` at line 264, The CLI capability row in the README table is missing
shipped commands and must be updated to match the documented CLI surface: modify
the CLI row (the table cell containing `init`, `discover`, `capabilities`, ...,
`serve`) to include the missing commands `review`, `expire`, `sync-check`,
`sync-apply`, and `diff` so the "What ships today" summary reflects the actual
shipped commands; ensure the final comma/spacing formatting matches the existing
list style.

Comment thread README.md
## Status

Pre-1.0. What's *not* in this implementation: vector embeddings (BM25/FTS5 only), per-runtime adapter templates, benchmark fixtures, multi-agent sync, scopes beyond a single field on Claim/Source. If a hole matters to you, file an issue.
Pre-1.0. What's *not* in this implementation: per-runtime adapter templates, benchmark fixtures, multi-agent sync, scopes beyond a single field on Claim/Source. If a hole matters to you, file an issue.

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

Remove the stale “multi-agent sync” limitation.

This line still says multi-agent sync is not implemented, but this PR adds sync-check / sync-apply and the multi-agent guide now documents the workflow. Leaving both claims in the docs is contradictory.

🤖 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 `@README.md` at line 278, The README line claiming "multi-agent sync" is not
implemented is now stale; update that sentence to remove or rephrase the
"multi-agent sync" limitation and instead note that multi-agent synchronization
is supported via the new CLI commands `sync-check` and `sync-apply` (and link or
reference the multi-agent guide). Locate the sentence containing "multi-agent
sync" in the Pre-1.0 section and edit it to either drop that limitation or
replace it with a short note pointing readers to the multi-agent guide and the
`sync-check`/`sync-apply` workflow.

Comment thread src/vouch/server.py
Comment on lines +431 to +432
result = expire_pending(
_store(), apply=apply, expired_by=EXPIRE_ACTOR, days=days,

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 | 🟠 Major | ⚡ Quick win

Preserve the actual caller when expiring proposals.

kb_expire() hard-codes EXPIRE_ACTOR here, so the transport drops VOUCH_AGENT before the lifecycle layer sees the request. That makes operator-triggered expiry indistinguishable from automatic expiry in any expired_by or audit fields produced downstream.

Suggested fix
         result = expire_pending(
-            _store(), apply=apply, expired_by=EXPIRE_ACTOR, days=days,
+            _store(), apply=apply, expired_by=_agent(), days=days,
         )
📝 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
result = expire_pending(
_store(), apply=apply, expired_by=EXPIRE_ACTOR, days=days,
result = expire_pending(
_store(), apply=apply, expired_by=_agent(), days=days,
🤖 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 `@src/vouch/server.py` around lines 431 - 432, kb_expire() currently calls
expire_pending(..., expired_by=EXPIRE_ACTOR) which overwrites the
transport-supplied caller (e.g., VOUCH_AGENT) so downstream lifecycle/audit
fields lose operator identity; change kb_expire() to pass the actual incoming
caller/actor into expire_pending via the expired_by parameter (use the actor
value supplied to kb_expire() or the transport context) instead of EXPIRE_ACTOR
so the original caller is preserved through expire_pending and lifecycle/audit.

Comment thread src/vouch/storage.py
Comment on lines 370 to 399
def update_claim(self, claim: Claim) -> Claim:
if not self._claim_path(claim.id).exists():
raise ArtifactNotFoundError(f"claim {claim.id}")
# Re-validate the in-memory Claim before persisting so model
# invariants (e.g. evidence must be non-empty — see #81) hold
# even when a caller mutated fields in place after get_claim().
# The Claim model's field validators only run at construction
# time; mutation alone bypasses them unless we round-trip.
Claim.model_validate(claim.model_dump(mode="json"))
self._claim_path(claim.id).write_text(_yaml_dump(claim.model_dump(mode="json")))
self._embed_and_store(kind="claim", id=claim.id, text=claim.text)
# Keep the FTS5 row in sync with the on-disk claim so lifecycle
# mutations (archive, supersede, contradict, confirm) are reflected
# in retrieval immediately. Without this, claims_fts.status stays
# frozen at first-index time and retracted claims keep matching
# kb.search / kb.context.
from . import index_db as _index_db

try:
with _index_db.open_db(self.kb_dir) as conn:
_index_db.index_claim(
conn, id=claim.id, text=claim.text,
type=claim.type.value, status=claim.status.value,
tags=list(claim.tags),
)
except sqlite3.Error as e:
_embed_log.warning(
"claim %s: FTS5 reindex skipped on update (%s)", claim.id, e,
)
return claim

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 | 🟠 Major | ⚡ Quick win

Keep update_claim() consistent with put_claim()'s citation existence checks.

This now re-validates the model shape, but it still skips the source/evidence existence loop that put_claim() runs. A caller can load a valid claim, mutate claim.evidence to an unknown id, and update_claim() will persist a broken claim that later fails citation resolution.

Suggested fix
     def update_claim(self, claim: Claim) -> Claim:
         if not self._claim_path(claim.id).exists():
             raise ArtifactNotFoundError(f"claim {claim.id}")
@@
-        Claim.model_validate(claim.model_dump(mode="json"))
-        self._claim_path(claim.id).write_text(_yaml_dump(claim.model_dump(mode="json")))
-        self._embed_and_store(kind="claim", id=claim.id, text=claim.text)
+        validated = Claim.model_validate(claim.model_dump(mode="json"))
+        for cid_or_sid in validated.evidence:
+            if (self._source_dir(cid_or_sid) / "meta.yaml").exists():
+                continue
+            if self._evidence_path(cid_or_sid).exists():
+                continue
+            raise ValueError(
+                f"claim {validated.id} cites unknown source/evidence {cid_or_sid}"
+            )
+        self._claim_path(validated.id).write_text(
+            _yaml_dump(validated.model_dump(mode="json"))
+        )
+        self._embed_and_store(kind="claim", id=validated.id, text=validated.text)
@@
                 _index_db.index_claim(
-                    conn, id=claim.id, text=claim.text,
-                    type=claim.type.value, status=claim.status.value,
-                    tags=list(claim.tags),
+                    conn, id=validated.id, text=validated.text,
+                    type=validated.type.value, status=validated.status.value,
+                    tags=list(validated.tags),
                 )
🤖 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 `@src/vouch/storage.py` around lines 370 - 399, update_claim currently
revalidates Claim shape but omits the citation/source existence checks that
put_claim performs, so mutated claim.evidence can reference unknown artifacts;
before writing and storing, replicate the same existence validation loop used in
put_claim (or extract it into a shared helper) to verify each evidence/source id
exists and raise ArtifactNotFoundError for any missing id, then proceed with
persistence, _embed_and_store and FTS5 reindexing as before.

Comment thread src/vouch/sync.py
Comment on lines +148 to +154
def _load_bundle_source(source_path: Path) -> _SyncSource:
with tarfile.open(source_path, "r:gz") as tar:
try:
member = tar.getmember(bundle.MANIFEST_NAME)
except KeyError as exc:
raise RuntimeError("bundle missing manifest.json") from exc
manifest = json.loads(tar.extractfile(member).read().decode()) # type: ignore[union-attr]

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

Translate invalid bundle files into a sync error.

Any regular file reaches _load_bundle_source(), but tarfile.open() can raise tarfile.ReadError here. The CLI only normalizes RuntimeError / ValueError, so vouch sync-check some.txt currently risks a traceback instead of a one-line user error.

Suggested fix
 def _load_bundle_source(source_path: Path) -> _SyncSource:
-    with tarfile.open(source_path, "r:gz") as tar:
+    try:
+        tar = tarfile.open(source_path, "r:gz")
+    except tarfile.TarError as exc:
+        raise RuntimeError(
+            f"sync source is not a readable vouch bundle: {source_path}"
+        ) from exc
+    with tar:
         try:
             member = tar.getmember(bundle.MANIFEST_NAME)
         except KeyError as exc:
             raise RuntimeError("bundle missing manifest.json") from exc
📝 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
def _load_bundle_source(source_path: Path) -> _SyncSource:
with tarfile.open(source_path, "r:gz") as tar:
try:
member = tar.getmember(bundle.MANIFEST_NAME)
except KeyError as exc:
raise RuntimeError("bundle missing manifest.json") from exc
manifest = json.loads(tar.extractfile(member).read().decode()) # type: ignore[union-attr]
def _load_bundle_source(source_path: Path) -> _SyncSource:
try:
tar = tarfile.open(source_path, "r:gz")
except tarfile.TarError as exc:
raise RuntimeError(
f"sync source is not a readable vouch bundle: {source_path}"
) from exc
with tar:
try:
member = tar.getmember(bundle.MANIFEST_NAME)
except KeyError as exc:
raise RuntimeError("bundle missing manifest.json") from exc
manifest = json.loads(tar.extractfile(member).read().decode()) # type: ignore[union-attr]
🤖 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 `@src/vouch/sync.py` around lines 148 - 154, _in _load_bundle_source, wrap the
tarfile.open(...) call in a try/except that catches tarfile.ReadError and
re-raises a normalized error (e.g., raise RuntimeError(f"invalid bundle file:
{source_path}") from exc) so invalid/regular files produce the same user-facing
sync error the CLI expects; keep the existing manifest lookup and JSON parsing
logic intact and preserve the original exception as the __cause__ when
re-raising.

Comment thread tests/test_cli_output.py
Comment on lines +30 to +56
def test_status_no_color_by_default_in_pipe(
store: KBStore, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("FORCE_COLOR", raising=False)
monkeypatch.delenv("NO_COLOR", raising=False)
result = CliRunner().invoke(cli, ["status"])
assert result.exit_code == 0, result.output
assert "\x1b[" not in result.output # no ANSI when not a TTY


def test_status_force_color_emits_ansi(
store: KBStore, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("FORCE_COLOR", "1")
monkeypatch.delenv("NO_COLOR", raising=False)
result = CliRunner().invoke(cli, ["status"])
assert result.exit_code == 0, result.output
assert "\x1b[" in result.output # ANSI present when forced


def test_no_color_wins_over_force_color(
store: KBStore, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("FORCE_COLOR", "1")
monkeypatch.setenv("NO_COLOR", "1")
result = CliRunner().invoke(cli, ["status"])
assert "\x1b[" not in result.output

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

Ensure Click preserves ANSI in CliRunner output
CliRunner().invoke() defaults to color=False, so captured result.output can strip ANSI escapes—making the "\x1b[" assertions not reliably exercise your FORCE_COLOR/NO_COLOR precedence. Pass color=True in these status invocations.

Suggested change
-    result = CliRunner().invoke(cli, ["status"])
+    result = CliRunner().invoke(cli, ["status"], color=True)
@@
-    result = CliRunner().invoke(cli, ["status"])
+    result = CliRunner().invoke(cli, ["status"], color=True)
@@
-    result = CliRunner().invoke(cli, ["status"])
+    result = CliRunner().invoke(cli, ["status"], color=True)
🤖 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_cli_output.py` around lines 30 - 56, The tests
test_status_no_color_by_default_in_pipe, test_status_force_color_emits_ansi, and
test_no_color_wins_over_force_color call CliRunner().invoke(cli, ["status"])
which defaults to color=False and can strip ANSI sequences; update each
invocation to pass color=True (i.e., CliRunner().invoke(cli, ["status"],
color=True) ) so the result.output preserves ANSI escape sequences and the
"\x1b[" assertions reliably test FORCE_COLOR/NO_COLOR precedence.

Comment thread tests/test_templates.py
Comment on lines +78 to +82
def test_init_unknown_template_clean_error(tmp_path: Path) -> None:
res = CliRunner().invoke(cli, ["init", "--path", str(tmp_path), "--template", "bogus"])
assert res.exit_code != 0
assert "Traceback" not in res.output
assert "unknown template" in res.output.lower()

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

Assert the available-template list in the CLI error.

The design contract here is stronger than "unknown template" alone: the error is supposed to tell the user which template names are valid. This test won't catch a regression that drops that guidance.

Suggested assertion
 def test_init_unknown_template_clean_error(tmp_path: Path) -> None:
     res = CliRunner().invoke(cli, ["init", "--path", str(tmp_path), "--template", "bogus"])
     assert res.exit_code != 0
     assert "Traceback" not in res.output
     assert "unknown template" in res.output.lower()
+    assert "gittensor" in res.output
+    assert "starter" in res.output
🤖 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_templates.py` around lines 78 - 82, Update
test_init_unknown_template_clean_error to assert the CLI error includes the list
of valid template names in addition to the current checks: after invoking cli in
that test, import or call the source of truth for templates (e.g.
AVAILABLE_TEMPLATES constant or get_available_template_names() used by cli) and
assert that each expected template name (or the joined list string) appears in
res.output (case-insensitive) so the failure message shows the
available-template guidance.

@plind-junior

Copy link
Copy Markdown
Collaborator

Review

Summary: This PR fixes the artifact-id path-traversal vulnerability described in #149 and bundles a large set of unrelated features (vouch fsck, vouch expire, vouch review, vouch diff, vouch sync-check/apply, batch approve, structured JSON logging, retrieval backend config, colorised CLI output). The security fix is solid and exactly matches the two-layer approach the issue specified. The bundled features are well-tested but out of scope for a targeted security patch.

What works

  • src/vouch/models.py:2980_validate_artifact_id rejects /, \\, .., NUL, ASCII control chars, and leading .. Covers all attack surfaces the issue identified (direct construction, bundle import via _validate_content, in-place mutation via update_claim re-validation).
  • src/vouch/storage.py:3694_safe_artifact_path adds belt-and-suspenders at the storage layer via resolve().relative_to(sub_dir), mirroring bundle._safe_member_path and read_under_root. Protects callers that pass ids directly (MCP/JSONL, lifecycle ops) without going through model construction.
  • src/vouch/storage.py:3742update_claim re-validates via Claim.model_validate(claim.model_dump(mode="json")) before writing, closing the in-place mutation bypass (bug: Claim model has no min-evidence validator — uncited claims land via bundle import, put_claim, update_claim #81 belt-and-suspenders pattern re-applied correctly).
  • tests/test_storage.py:6073 — Per-model traversal tests for Claim, Entity, Relation, Page, Evidence; NUL, leading-dot, empty/whitespace rejection.
  • tests/test_storage.py:6135 — Storage-layer guard tested for all seven _<X>_path helpers.
  • tests/test_storage.py:6158 — In-place mutation (c.id = "../escape") + update_claim rejects before any write.
  • tests/test_bundle.py:4327 — Bundle with id: "../escape" in the YAML body rejected by import_check and import_apply; no off-tree file.
  • tests/test_health.py:5393 — Legacy KB with traversal-id claim YAML surfaces as invalid_claim finding via vouch lint — correct migration path.
  • src/vouch/sessions.py:3596 — Summary page body now strips agent-controlled fields (sess.task, sess.note, sess.agent), closing bug: crystallize writes a durable Page bypassing the review gate; body embeds agent-controlled markdown verbatim #76.
  • src/vouch/context.py:2153_RETRACTED_CLAIM_STATUSES filter correctly excludes archived/superseded/redacted claims from context packs (bug: kb.context returns archived/superseded claims; lifecycle mutations never re-index FTS5 status #78), and update_claim now re-indexes FTS5 so the status column stays in sync.
  • src/vouch/storage.py:3649VOUCH_KB_PATH env var is finally wired into discover_root.

Suggestions

  • [non-blocking] src/vouch/health.py:2720_check_orphan_embeddings executes f"SELECT kind, id FROM {table}" with table interpolated from a hardcoded loop variable ("embeddings", "embedding_index"). Safe today because both values are literals in the source, but the pattern opens a SQLi surface if the loop ever sources its names from external input. A table in ("embeddings", "embedding_index") assertion before the query would make the intent explicit and protect the pattern under future refactors.

  • [non-blocking] src/vouch/sync.py:3959_read_source_file calls tar.extractfile(tar.getmember(path)).read() but does not check whether the member escapes the KB root (no call to bundle._safe_member_path for the read path). sync_check does call bundle._safe_member_path on each incoming file, so the check happens before write, but a malicious bundle could still redirect a read to an absolute path during sync_apply. Low severity — the unsafe-name check in _validation_issues_for_source runs first and would abort before _read_source_file is reached — but making the read unconditionally safe would be cleaner.

  • [non-blocking] src/vouch/proposals.py:3480expire_one mutates proposal.status, decided_at, decided_by, and decision_reason in-place on the Pydantic model before calling store.move_proposal_to_decided. Pydantic v2 models are mutable by default, so this works, but it diverges from the immutable-payload idiom used in approve() (payload = dict(proposal.payload) → construct a new object). The in-place style is fine functionally but worth flagging for consistency.

  • [non-blocking] docs/PROJECT-INDEX.md — This 550-line generated index is new and lists a module count (27 files) and statistics that will drift as the project evolves. Consider either removing it (the src/ tree is navigable without it) or noting it needs to be regenerated. It is not a blocking concern, but auto-generated docs committed to main tend to become stale markers.

  • [non-blocking] PR title/scope — The PR title is "Fix/artifact id path traversal" but the diff includes vouch fsck, vouch expire, vouch review, vouch diff, vouch sync-check/apply, batch approve, structured logging, retrieval backend config, and CLI colorisation. These are all useful and well-tested, but mixing security fixes with feature work makes bisect harder and increases review surface. Consider splitting in future; not a merge blocker for this branch.

Verdict

approve — The core security fix (model-layer _validate_artifact_id + storage-layer _safe_artifact_path + update_claim re-validation) is correct, covers all three attack paths from #149, and has regression tests for every variant listed in the issue. The bundled features are net-positive and the test suite looks solid. The suggestions above are all non-blocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

validation gap: artifact ids accept path-traversal strings and reach filesystem paths unsanitised in storage operations

10 participants