diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f22048..e3f6f5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 1393d978..677f5de6 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -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` @@ -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): diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 2024164c..30e85714 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -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) @@ -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) 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: diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 6908a3cb..cd133a8e 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -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." ) @@ -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() == {} @@ -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() == {} diff --git a/tests/test_storage.py b/tests/test_storage.py index 7e0ccdd6..decb2a25 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -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 /meta.yaml or /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 --------------------------------------------------------------- @@ -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."""