Summary
Artifact ids are interpolated directly into filesystem paths on the durable write path, with no validation. Because an untrusted proposer fully controls the id (via slug_hint), an approved artifact can be written outside the KB, defeating the review gate.
Where
- Sink:
src/vouch/storage.py — _yaml, _page_path, _source_dir build paths as kb_dir / sub / f"{obj_id}.yaml" etc. with obj_id used verbatim.
- Taint source:
src/vouch/proposals.py — propose_claim/propose_page/propose_entity/propose_relation set "id": slug_hint or _slugify(...), i.e. slug_hint is used raw.
- Missing validation:
src/vouch/models.py — Claim.id, Page.id, Entity.id, Relation.id have no validator (only Source.id is validated as a hex sha256).
Impact
The project's threat model assumes the proposing agent is not fully trusted ("they shouldn't get to write whatever they want either"). A malicious or prompt-injected agent can propose a claim/page/entity with slug_hint="../../../../../../tmp/evil". On approval, Claim(**payload) (no id validation) is handed to store.put_claim, which resolves:
<root>/.vouch/claims/../../../../../../tmp/evil.yaml → /tmp/evil.yaml
i.e. an attacker-controlled YAML/markdown file written anywhere the process can write (overwrite configs, drop files into other repos, clobber dotfiles), or overwrite other artifacts inside the KB. This is fully autonomous under the trusted-agent auto-approve mode.
The read path is already hardened (read_under_root with containment + O_NOFOLLOW + fstat) and bundle import already rejects ../absolute/nul member names (bundle._unsafe_name_reason) — but the primary write path has no equivalent guard. That asymmetry is the bug.
Steps to reproduce
from vouch.storage import KBStore
from vouch.proposals import propose_claim, approve
store = KBStore.init(tmp_path)
src = store.put_source(b"x")
pr = propose_claim(store, text="t", evidence=[src.id],
proposed_by="agent", slug_hint="../../../../evil")
approve(store, pr.id, approved_by="reviewer") # writes evil.yaml OUTSIDE the KB
Expected
Approval (and any direct put_*) must refuse an id that contains a path separator, .., an absolute prefix, or a nul byte.
Fix
Add a write-side validator at the single point where ids become filenames (mirrors the existing read-side / bundle hardening), so all entrypoints (MCP, JSONL, CLI, direct KBStore) are covered. PR attached.
Summary
Artifact ids are interpolated directly into filesystem paths on the durable write path, with no validation. Because an untrusted proposer fully controls the id (via
slug_hint), an approved artifact can be written outside the KB, defeating the review gate.Where
src/vouch/storage.py—_yaml,_page_path,_source_dirbuild paths askb_dir / sub / f"{obj_id}.yaml"etc. withobj_idused verbatim.src/vouch/proposals.py—propose_claim/propose_page/propose_entity/propose_relationset"id": slug_hint or _slugify(...), i.e.slug_hintis used raw.src/vouch/models.py—Claim.id,Page.id,Entity.id,Relation.idhave no validator (onlySource.idis validated as a hex sha256).Impact
The project's threat model assumes the proposing agent is not fully trusted ("they shouldn't get to write whatever they want either"). A malicious or prompt-injected agent can propose a claim/page/entity with
slug_hint="../../../../../../tmp/evil". On approval,Claim(**payload)(no id validation) is handed tostore.put_claim, which resolves:i.e. an attacker-controlled YAML/markdown file written anywhere the process can write (overwrite configs, drop files into other repos, clobber dotfiles), or overwrite other artifacts inside the KB. This is fully autonomous under the
trusted-agentauto-approve mode.The read path is already hardened (
read_under_rootwith containment +O_NOFOLLOW+fstat) and bundle import already rejects../absolute/nul member names (bundle._unsafe_name_reason) — but the primary write path has no equivalent guard. That asymmetry is the bug.Steps to reproduce
Expected
Approval (and any direct
put_*) must refuse an id that contains a path separator,.., an absolute prefix, or a nul byte.Fix
Add a write-side validator at the single point where ids become filenames (mirrors the existing read-side / bundle hardening), so all entrypoints (MCP, JSONL, CLI, direct
KBStore) are covered. PR attached.