Skip to content
Open
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 @@ -281,6 +281,7 @@ All notable changes to vouch are documented here. Format follows
KB under `eval/fixture-kb/`, and an `eval` workflow gating retrieval changes
(#226).
### Fixed
- artifact ids that contain a path separator, `..`, a nul byte, or are empty are now rejected at the storage layer, closing a path-traversal hole. `Source.id` is hex-locked, but the other models (`Claim`, `Page`, `Entity`, `Relation`, `Evidence`, `Session`, `Proposal`) carry a bare `id: str`, so a poisoned id — e.g. one smuggled through a bundle body under a safe on-disk filename — could resolve to a path outside `kb_dir` on the next put / update / lifecycle write (or read an arbitrary file on get). The guard sits at the `_yaml` / `_page_path` chokepoint every `_*_path` helper funnels through, mirroring the `bundle._safe_member_path` tar-traversal fix (fixes #149).
- `Claim.text`, `Entity.name`, and `Page.title` now reject empty / whitespace-only values at the model layer via `@field_validator`s, mirroring the `Claim.evidence` min-citation validator (#81/#82). The non-empty contract previously lived only in the `propose_*` helpers, so `store.put_*`, `store.update_*`, and bundle/sync import all silently accepted blank-content artifacts; enforcing on the model closes every write path at once (fixes #155).
- `parse_since` (the `--since` parser behind `vouch metrics`/`vouch audit`) now raises a clean `MetricsError` for a duration too large to represent (e.g. `--since 1000000000000d`), instead of letting an uncaught `OverflowError` traceback escape — restoring the documented "clean error, not a traceback" contract.
- `sync_apply` now loads the sync source exactly once and passes the same `_SyncSource` instance into `sync_check`, closing a TOCTOU window where a bundle replaced on disk between the two `_load_source` calls could cause the validation and write phases to operate on different snapshots. Also eliminates redundant directory walks (KB sources) and triple tarball opens (bundle sources). Fixes #217.
Expand Down
20 changes: 11 additions & 9 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@

_log = logging.getLogger(__name__)

# Path to the plugin manifest, relative to this module. capabilities.py lives
# at src/vouch/capabilities.py; openclaw.plugin.json lives at the repo root,
# three levels up (src/vouch/ -> src/ -> repo root).
_PLUGIN_MANIFEST_PATH = Path(__file__).resolve().parent.parent.parent / "openclaw.plugin.json"
# Path to the loader-facing package.json at the repo root, three levels up
# (src/vouch/ -> src/ -> repo root). The 2026.6 openclaw repackage moved the
# `openclaw.compat.pluginApi` floor out of openclaw.plugin.json (whose
# `openclaw.*` fields the current loader ignores) and into package.json, so
# that is now the single source of truth for host compat (#237).
_PACKAGE_JSON_PATH = Path(__file__).resolve().parent.parent.parent / "package.json"

# The full method surface this implementation exposes. Keep this list in
# sync with the MCP server + JSONL server registrations — `test_capabilities`
Expand Down Expand Up @@ -90,19 +92,19 @@


def _load_host_compat() -> dict[str, dict[str, str]]:
"""Read the `openclaw.compat` block from openclaw.plugin.json (#237).
"""Read the `openclaw.compat` block from package.json (#237).

Surfaced in `kb.capabilities` as `host_compat` so non-OpenClaw clients
can detect compat without parsing the manifest themselves. Returns an
empty dict (rather than raising) if the manifest is missing or
empty dict (rather than raising) if package.json is missing or
malformed — capabilities() must never fail to report basic info just
because the manifest moved or this is installed as a standalone wheel
without the manifest packaged alongside it.
without package.json packaged alongside it.
"""
try:
manifest = json.loads(_PLUGIN_MANIFEST_PATH.read_text(encoding="utf-8"))
manifest = json.loads(_PACKAGE_JSON_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
_log.debug("openclaw.plugin.json unreadable, host_compat will be empty: %s", e)
_log.debug("package.json unreadable, host_compat will be empty: %s", e)
return {}
compat = manifest.get("openclaw", {}).get("compat")
if not isinstance(compat, dict):
Expand Down
29 changes: 29 additions & 0 deletions src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,29 @@ def _yaml_load(text: str) -> Any:
return yaml.safe_load(text)


def _reject_unsafe_id(obj_id: str) -> None:
"""Reject an artifact id that would escape its subdirectory as a path.

`Source.id` is locked to a hex sha256, but every other artifact model
(`Claim`, `Page`, `Entity`, `Relation`, `Evidence`, `Session`,
`Proposal`) declares a bare `id: str` with no validator. A poisoned id —
e.g. ``"../../tmp/x"`` smuggled through a bundle body that `import_apply`
lands under a safe *filename* but whose in-memory id is traversal — would
otherwise resolve to a path outside `kb_dir` on the next put / update /
lifecycle write (or read arbitrary files on get). This is the single
chokepoint every `_*_path` helper funnels through, so guarding it closes
all reach paths at once. Mirrors `bundle._unsafe_name_reason` (#149).
"""
if not obj_id:
raise ValueError("artifact id must not be empty")
if "\x00" in obj_id:
raise ValueError(f"artifact id contains a nul byte: {obj_id!r}")
if "/" in obj_id or "\\" in obj_id:
raise ValueError(f"artifact id must not contain a path separator: {obj_id!r}")
if obj_id in (".", "..") or ".." in Path(obj_id).parts:
raise ValueError(f"path traversal in artifact id: {obj_id!r}")


_log = logging.getLogger("vouch.storage")

_ModelT = TypeVar("_ModelT", bound=BaseModel)
Expand Down Expand Up @@ -263,15 +286,21 @@ def config_path(self) -> Path:
return self.kb_dir / CONFIG_FILENAME

def _yaml(self, sub: str, obj_id: str) -> Path:
_reject_unsafe_id(obj_id)
return self.kb_dir / sub / f"{obj_id}.yaml"

def _claim_path(self, claim_id: str) -> Path:
return self._yaml("claims", claim_id)

def _page_path(self, page_id: str) -> Path:
_reject_unsafe_id(page_id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return self.kb_dir / "pages" / f"{page_id}.md"

def _source_dir(self, source_id: str) -> Path:
# Source.id is hex-sha256-locked on the model, but get_source /
# read_source_content / the evidence-ref existence checks route raw
# id strings through here, so guard this chokepoint too (#149).
_reject_unsafe_id(source_id)
return self.kb_dir / "sources" / source_id

def _entity_path(self, eid: str) -> Path:
Expand Down
31 changes: 16 additions & 15 deletions tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,32 @@ def test_capabilities_matches_jsonl_handlers() -> None:

# --- host_compat drift detection (#237) -----------------------------------
#
# vouch declares openclaw.compat.pluginApi in openclaw.plugin.json. The same
# value must surface in kb.capabilities so non-OpenClaw clients can detect
# compat without parsing the manifest. These tests fail CI with a clear
# message if the two declarations drift apart.

_MANIFEST_PATH = (
Path(__file__).resolve().parent.parent / "openclaw.plugin.json"
# vouch declares openclaw.compat.pluginApi in package.json (the loader-facing
# manifest the 2026.6 repackage moved it to). The same value must surface in
# kb.capabilities so non-OpenClaw clients can detect compat without parsing
# the manifest. These tests fail CI with a clear message if the two
# declarations drift apart.

_PACKAGE_JSON_PATH = (
Path(__file__).resolve().parent.parent / "package.json"
)


def _manifest_plugin_api() -> str:
manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8"))
manifest = json.loads(_PACKAGE_JSON_PATH.read_text(encoding="utf-8"))
return manifest["openclaw"]["compat"]["pluginApi"]


def test_capabilities_host_compat_matches_openclaw_manifest() -> None:
"""kb.capabilities.host_compat.openclaw.pluginApi must equal the
pluginApi range declared in openclaw.plugin.json. A bump in one file
pluginApi range declared in package.json. A bump in one file
without the other is exactly the "host compat drift" #237 asks CI to
catch."""
caps = capabilities.capabilities()
manifest_range = _manifest_plugin_api()
capabilities_range = caps.host_compat.get("openclaw", {}).get("pluginApi")
assert capabilities_range == manifest_range, (
f"host compat drift: openclaw.plugin.json declares pluginApi="
f"host compat drift: package.json declares pluginApi="
f"{manifest_range!r} but kb.capabilities.host_compat reports "
f"{capabilities_range!r}. Keep both in sync."
)
Expand All @@ -68,10 +69,10 @@ def test_load_host_compat_returns_empty_on_missing_manifest(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
) -> None:
"""_load_host_compat must degrade gracefully (empty dict, no raise) if
the manifest is absent -- e.g. installed as a standalone wheel without
openclaw.plugin.json packaged alongside it."""
package.json is absent -- e.g. installed as a standalone wheel without
package.json packaged alongside it."""
monkeypatch.setattr(
capabilities, "_PLUGIN_MANIFEST_PATH", tmp_path / "does-not-exist.json"
capabilities, "_PACKAGE_JSON_PATH", tmp_path / "does-not-exist.json"
)
assert capabilities._load_host_compat() == {}

Expand All @@ -81,7 +82,7 @@ def test_load_host_compat_returns_empty_on_malformed_manifest(
) -> None:
"""A malformed manifest must not crash capabilities() -- it's reporting
diagnostic info, not validating the install."""
bad = tmp_path / "openclaw.plugin.json"
bad = tmp_path / "package.json"
bad.write_text("{not valid json", encoding="utf-8")
monkeypatch.setattr(capabilities, "_PLUGIN_MANIFEST_PATH", bad)
monkeypatch.setattr(capabilities, "_PACKAGE_JSON_PATH", bad)
assert capabilities._load_host_compat() == {}
71 changes: 71 additions & 0 deletions tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ def test_source_hash_field_mirrors_id(store: KBStore) -> None:
assert s.hash == s.id


def test_get_source_rejects_traversal_id(store: KBStore) -> None:
# get_source / read_source_content take raw id strings; a crafted id must
# not resolve to <outside>/meta.yaml or <outside>/content (#149).
with pytest.raises(ValueError, match="artifact id"):
store.get_source("../../../etc/hostname")
with pytest.raises(ValueError, match="artifact id"):
store.read_source_content("../../outside")


# --- claims ---------------------------------------------------------------


Expand Down Expand Up @@ -258,6 +267,68 @@ def test_page_with_frontmatter_round_trip(store: KBStore) -> None:
assert back.type == PageType.CONCEPT


# --- artifact id path-traversal guard (#149) ------------------------------


@pytest.mark.parametrize(
"bad_id",
["../escape", "../../etc/passwd", "a/b", "sub\\evil", "/abs", "..", ""],
)
def test_yaml_path_rejects_unsafe_id(store: KBStore, bad_id: str) -> None:
with pytest.raises(ValueError, match="artifact id"):
store._yaml("claims", bad_id)


@pytest.mark.parametrize("bad_id", ["../../pwned", "a/b", "/abs"])
def test_page_path_rejects_unsafe_id(store: KBStore, bad_id: str) -> None:
with pytest.raises(ValueError, match="artifact id"):
store._page_path(bad_id)


def test_put_claim_rejects_traversal_id(store: KBStore) -> None:
src = store.put_source(b"e")
with pytest.raises(ValueError, match="artifact id"):
store.put_claim(Claim(id="../../../tmp/pwned", text="x", evidence=[src.id]))


def test_get_claim_rejects_traversal_id(store: KBStore) -> None:
# A crafted id on a read path must not resolve to an arbitrary file.
with pytest.raises(ValueError, match="artifact id"):
store.get_claim("../../../etc/hostname")


def test_put_page_rejects_traversal_id(store: KBStore) -> None:
with pytest.raises(ValueError, match="artifact id"):
store.put_page(Page(id="../../../tmp/pwned", title="x"))


def test_update_claim_rejects_disk_smuggled_traversal_id(store: KBStore) -> None:
"""The bundle exploit: a claim lands under a *safe* filename but carries a
traversal string in its `id` field (models don't validate non-Source ids).
Reading it is fine; the next write must refuse to escape kb_dir."""
src = store.put_source(b"e")
planted = Claim(id="innocent", text="looks fine", evidence=[src.id])
on_disk = {**planted.model_dump(mode="json"), "id": "../../../tmp/pwned"}
claim_file = store.kb_dir / "claims" / "innocent.yaml"
claim_file.write_text(_yaml_dump(on_disk))

c = store.get_claim("innocent")
assert c.id == "../../../tmp/pwned" # in-memory id is traversal

with pytest.raises(ValueError, match="artifact id"):
store.update_claim(c)
# nothing escaped, and the planted file is untouched by the refused write.
assert claim_file.read_text() == _yaml_dump(on_disk)


def test_legitimate_ids_still_resolve(store: KBStore) -> None:
"""Regression guard: normal slug / hex ids must keep working."""
for sub, cid in [("claims", "auth-uses-jwt"), ("entities", "proj_foo"),
("sessions", "a1b2c3d4")]:
assert store._yaml(sub, cid) == store.kb_dir / sub / f"{cid}.yaml"
assert store._page_path("overview") == store.kb_dir / "pages" / "overview.md"


@pytest.mark.parametrize("bad", ["", " ", "\n"])
def test_page_model_rejects_empty_title(bad: str) -> None:
"""Regression for #155 — empty / whitespace titles rejected on the model."""
Expand Down
Loading