diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e9ae06517..f0922b491 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -44,7 +44,7 @@ body: attributes: label: Affected commit SHA description: Exact LCM-X commit that exhibits the bug. Add a branch or release tag only as context. - placeholder: Exact commit SHA for v0.23.0-rc1 or main, for example 0123456789abcdef... + placeholder: Exact commit SHA for v0.23.2 or main, for example 0123456789abcdef... validations: required: true diff --git a/.github/release-notes/v0.23.2-rc2.md b/.github/release-notes/v0.23.2-rc2.md new file mode 100644 index 000000000..a08251246 --- /dev/null +++ b/.github/release-notes/v0.23.2-rc2.md @@ -0,0 +1,86 @@ +# v0.23.2-rc2 — lossless default, security, and instrument-integrity release candidate + +**RELEASE CANDIDATE — not GA.** This prerelease enters the RELEASE-READINESS-V1 gauntlet +(bench/specs/RELEASE-READINESS-V1.md): Phase A (all-tools live matrix on a fresh clone across +privacy postures, planted-secret battery), Phase B (multi-agent P0/P1 sweep of the full +v0.23.1→rc1 diff), Phase C (30+ turn live session soak). GA follows only with all three +receipts green. + +## Lossless by default: the privacy split (#346 → #374) + +LCM sells lossless memory, and v0.23.2 makes that the shipped default everywhere. Durable +sensitive-pattern redaction (`LCM_SENSITIVE_PATTERNS_ENABLED`) is now a fully independent, +opt-in-forever choice: it is off by default and no other feature turns it on. Cloud embedding +no longer requires it — known cloud providers instead protect only the **provider-bound +copy** of your text, controlled by the new `LCM_EMBEDDING_PRIVACY_ENABLED` flag (unset = +auto-on for cloud). Your durable history stays byte-for-byte lossless while the text leaving +the machine is transformed. Operators who want raw provider payloads can opt out explicitly +with `LCM_EMBEDDING_PRIVACY_ENABLED=false`, which binds vectors to the distinguished +`privacy:off` revision. Changing posture changes vector identity: dispatch refuses until a +new `/lcm embed warmup` registers it, and existing vectors stay ineligible until +`/lcm embed backfill --apply` re-embeds them — LCM never re-embeds automatically. + +Rerank payloads are covered by the same resolution (#371): with reranking enabled on a cloud +provider, the rerank query and every snippet are transformed before transport, and ranking +maps back to the original untransformed hits. + +## Security: PEM private-key redaction hardening (#365 → #366) + +v0.23.1's privacy release contained an ordering flaw: an assignment-style prefix +(`password:` / `passphrase=`) before a PEM block could consume the `-----BEGIN` marker and ship +the private-key body to the cloud embedding provider and durable storage. No customer exposure: +the repository is pre-release with no external deployments; vectors embedded under the previous +transform become ineligible (privacy revision v1 → v3) until the standard +`/lcm embed warmup` + `/lcm embed backfill --apply` sequence re-embeds them. + +The fix went through twenty-five adversarial cross-model review rounds and grew into a rewrite of the +redaction scanner around a normalized line model with a **declared threat model** (module +comment): accidental leakage of PEM-structured material in realistic paste/log/transcript shapes +— line-separator and whitespace variants, indentation and label/log prefixes, truncation, +stripped markers, re-wrapped widths, RFC 1421 encrypted-key armor, JSON/log serialization at any +escaping depth, and their compositions — with the dispatch validator as a fail-closed second +layer on the cloud path. Deliberate re-encoding remains out of scope for any content-based +redactor. Performance is linear and bounded (the pathological inputs that took 13.9–34.6s now +run in milliseconds; CodeQL ReDoS finding closed). + + +**rc2 (#383/#384):** the v0.23.2-rc1 Phase-A gauntlet caught a residual the #366 rewrite missed — +a truncated PRIVATE KEY body sharing a line with a following inline PEM marker (any armor label) +escaped to the provider copy, and the residual validator missed it. rc2 closes it at both layers: +the scanner redacts leading base64-body runs before inline markers, and — the durable fix — the +validator is now marker-INDEPENDENT (it blocks any dispatch where a 16+ character key-body fragment +survives beside a private-key marker or placeholder), so a future scanner miss fails closed instead +of leaking. This is the rc-first gate working as intended: the leak never reached a GA build. + +## Instrument integrity: loud privacy failures + production-posture harness (#367 → #370) + +- `lcm_recall` no longer silently degrades to FTS-only on a privacy-policy configuration error — + the error is loud at every handler site, and the proactive-recall path surfaces it via a + dedicated status counter plus a once-per-engine-instance warning. +- The V1-M harness now runs cloud providers at production posture — post-#374 that means the + lossless-durable / protected-provider-copy split: the transform applies to every cloud-bound + payload (documents, queries, probes), vector identities register under the active privacy + revision, resume artifacts bind the posture and refuse mismatches, and non-embedding runs + keep a byte-identical raw corpus. +- Provider aliases (`openai`, `siliconflow`) can no longer bypass the cloud-privacy predicate. + +## Release process: rc-first gauntlet (#373) + +Any release containing product code is now rc-first: this prerelease is the first through the +three-phase live gauntlet, and the GA commit may differ from the passing rc tree only by the +added release notes. The Phase-A runner discovers the tool surface at runtime (15 tools), +binds release runs to a clean tree at the exact rc tag, and records hermes provenance in its +receipt. + +## Known follow-ups (tracked, non-blocking) + +- #372 — assignment-pattern fragments around an embedded PEM placeholder. +- #369 — merge-gate governance right-sizing (reset scoping, approval-count rule). +- Live rerank battery in Phase A (rerank protection is covered by product regressions). + +## Benchmark boundary + +F53's reproducibility on current main is UNVERIFIED (not proven broken); the re-bank runs after +this release so the row binds to the stable privacy transform under the new posture split, and +its registration must declare the harness transform posture. See bench/BASELINE-LEDGER.md +(privacy-trio boundary) and the FINDING-F53 reproducibility note. diff --git a/CHANGELOG.md b/CHANGELOG.md index 89029f22c..11cc78e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ version tags but does not have a destination GitHub Release object for RC2. ## Unreleased +## v0.23.2 - security + lossless point release + - Lossless by default: durable sensitive-pattern redaction (`LCM_SENSITIVE_PATTERNS_ENABLED`, default off) is now fully independent of cloud-embedding privacy. Known cloud providers protect only the provider-bound copy, controlled by the new `LCM_EMBEDDING_PRIVACY_ENABLED` diff --git a/README.md b/README.md index b83244177..7df534ada 100644 --- a/README.md +++ b/README.md @@ -122,14 +122,15 @@ default-off experimental features. independent — see "Sensitive-pattern redaction" below.) Stable release identity and the continuing `main` development line are -separate proof planes. The source snapshot used for the current reconciliation -is `main@3d4fbb4c979dc09aef0b831bb50d928e0e18d68f`; do not describe an -arbitrary main checkout as the installed stable product. +separate proof planes; do not describe an arbitrary `main` checkout as the +installed stable product. -That exact development baseline still identifies itself as -`hermes-lcm v0.23.0-rc1 (15 tools)`. The stable tag identifies itself as -`hermes-lcm v0.23.1 (15 tools)`. This is tracked as a deferred version-policy -decision rather than being silently restamped by documentation changes. +The reconciled `main` line now identifies itself as +`hermes-lcm v0.23.2 (15 tools)` — aligned forward from the `v0.23.1` stable +tag, which identifies itself as `hermes-lcm v0.23.1 (15 tools)`. This resolves +the earlier drift where `main` declared an older release candidate than the +shipped stable tag (#385); it is a forward bump, never a restamp of any past +commit's own recorded identity. Eva has accepted exact stable v0.23.1 with hosted `voyage-4-large`, 1024-dimensional float32 summary vectors under one privacy-bound identity. @@ -253,7 +254,7 @@ Provider Plugins: ``` An untagged checkout of the reconciled `main` baseline instead reports -`hermes-lcm v0.23.0-rc1 (15 tools)`; verify the loaded commit before treating +`hermes-lcm v0.23.2 (15 tools)`; verify the loaded commit before treating either string as release proof. For source checkouts, `lcm_status`, `/lcm status`, `lcm_inspect`, diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 5fa0084c7..a8c2e2e9b 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -177,8 +177,11 @@ Provider Plugins: ``` An untagged checkout of the reconciled `main` baseline instead reports -`hermes-lcm v0.23.0-rc1 (15 tools)`. Version text alone is not release proof; -verify the loaded commit and tag. +`hermes-lcm v0.23.2 (15 tools)`. Version text alone is not release proof; +verify the loaded commit and tag. `0.23.2` is the stable identity the next GA +tag (`v0.23.2`) will carry unchanged — the release candidate and the GA cut +share this exact tree and differ only by the added release-notes file, so this +guide stays accurate across that transition without an edit. For source checkouts, `lcm_status`, `/lcm status`, `lcm_inspect`, `lcm_doctor`, and `/lcm doctor` also report the loaded plugin path and diff --git a/plugin.yaml b/plugin.yaml index ba03c25c9..58c940c86 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: hermes-lcm -version: 0.23.0-rc1 +version: 0.23.2 description: "LCM-X — Lossless Context Memory eXtension for Hermes, with a recoverable DAG-based context engine" author: "Voltropy / Hermes Community" provides_tools: diff --git a/tests/test_lcm_command.py b/tests/test_lcm_command.py index 0448df05f..b7d2322bc 100644 --- a/tests/test_lcm_command.py +++ b/tests/test_lcm_command.py @@ -490,7 +490,7 @@ def test_lcm_status_reports_runtime_identity(engine): repo_root = Path(__file__).resolve().parent.parent assert "plugin_name: hermes-lcm" in result - assert "plugin_version: 0.23.0-rc1" in result + assert "plugin_version: 0.23.2" in result assert f"plugin_path: {repo_root}" in result assert "module_path:" in result assert "database_path_source: config.database_path" in result @@ -530,7 +530,7 @@ def test_lcm_doctor_reports_health_checks(engine): assert "messages_fts: ok" in result assert "nodes_fts: ok" in result assert "plugin_name: hermes-lcm" in result - assert "plugin_version: 0.23.0-rc1" in result + assert "plugin_version: 0.23.2" in result assert f"plugin_path: {repo_root}" in result assert "plugin_git_commit:" in result assert "triage_guidance:\n- none" in result diff --git a/tests/test_lcm_engine.py b/tests/test_lcm_engine.py index 4dd801e03..f97c75fec 100644 --- a/tests/test_lcm_engine.py +++ b/tests/test_lcm_engine.py @@ -1345,7 +1345,7 @@ def test_get_status_exposes_runtime_identity_for_loaded_plugin_tree(tmp_path): assert identity["engine"] == "lcm" assert identity["plugin_name"] == "hermes-lcm" - assert identity["plugin_version"] == "0.23.0-rc1" + assert identity["plugin_version"] == "0.23.2" assert Path(identity["plugin_path"]) == repo_root assert Path(identity["module_path"]).name == "engine.py" assert Path(identity["database_path"]) == db_path @@ -1371,11 +1371,11 @@ def test_plugin_metadata_refreshes_when_manifest_changes(tmp_path, monkeypatch): initial = identity_mod._plugin_metadata() assert initial["name"] == "hermes-lcm" - assert initial["version"] == "0.23.0-rc1" + assert initial["version"] == "0.23.2" - updated = original.replace('version: "0.23.0-rc1"', 'version: "9.9.9-test"') + updated = original.replace('version: "0.23.2"', 'version: "9.9.9-test"') if updated == original: - updated = original.replace('version: 0.23.0-rc1', 'version: 9.9.9-test') + updated = original.replace('version: 0.23.2', 'version: 9.9.9-test') assert updated != original try: @@ -1413,7 +1413,7 @@ def test_lcm_doctor_json_includes_runtime_identity(engine): payload = json.loads(engine.handle_tool_call("lcm_doctor", {})) assert payload["runtime_identity"]["plugin_name"] == "hermes-lcm" - assert payload["runtime_identity"]["plugin_version"] == "0.23.0-rc1" + assert payload["runtime_identity"]["plugin_version"] == "0.23.2" assert "plugin_git_commit" in payload["runtime_identity"] diff --git a/tests/test_packaging_install.py b/tests/test_packaging_install.py index cf20939ac..c628e3f7a 100644 --- a/tests/test_packaging_install.py +++ b/tests/test_packaging_install.py @@ -531,7 +531,7 @@ def test_plugin_entrypoint_registers_lcm_context_engine(_isolate_plugin_registra identity = engine.get_status()["runtime_identity"] repo_root = Path(__file__).resolve().parent.parent assert identity["plugin_name"] == "hermes-lcm" - assert identity["plugin_version"] == "0.23.0-rc1" + assert identity["plugin_version"] == "0.23.2" assert Path(identity["plugin_path"]) == repo_root assert identity["database_path_source"] == "hermes_home" assert Path(identity["database_path"]) == _isolate_plugin_registration_storage / "lcm.db" diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 29ba47f3e..8a8553cbf 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -3,8 +3,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml" -RELEASE_VERSION = "0.23.0-rc1" -RELEASE_NOTES = REPO_ROOT / ".github" / "release-notes" / f"v{RELEASE_VERSION}.md" +IDENTITY_VERSION = "0.23.2" +RC_TAG = "0.23.2-rc2" +RELEASE_NOTES = REPO_ROOT / ".github" / "release-notes" / f"v{RC_TAG}.md" def test_release_workflow_requires_curated_tag_specific_notes(): @@ -35,11 +36,27 @@ def test_release_candidate_identity_surfaces_are_synchronized(): REPO_ROOT / ".github" / "ISSUE_TEMPLATE" / "bug_report.yml" ).read_text(encoding="utf-8") - assert f"version: {RELEASE_VERSION}" in manifest - assert f"hermes-lcm v{RELEASE_VERSION} (15 tools)" in readme - assert f"hermes-lcm v{RELEASE_VERSION} (15 tools)" in operator_guide - assert f"## v{RELEASE_VERSION} - " in changelog - assert f"Exact commit SHA for v{RELEASE_VERSION} or main" in bug_report + assert f"version: {IDENTITY_VERSION}" in manifest + assert f"hermes-lcm v{IDENTITY_VERSION} (15 tools)" in readme + assert f"hermes-lcm v{IDENTITY_VERSION} (15 tools)" in operator_guide + assert f"## v{IDENTITY_VERSION} - " in changelog + assert f"Exact commit SHA for v{IDENTITY_VERSION} or main" in bug_report + + +def test_plugin_version_is_not_a_release_downgrade(): + import re + + manifest = (REPO_ROOT / "plugin.yaml").read_text(encoding="utf-8") + match = re.search(r"^version:\s*(\S+)", manifest, re.MULTILINE) + assert match is not None, "plugin.yaml declares no version" + declared = match.group(1) + # Parse the release core (drop any -rcN suffix) and assert it never regresses + # below the last shipped stable — a restored older RC (e.g. 0.23.0-rc1) fails + # this even if IDENTITY_VERSION were changed with it (not tautological). + core = tuple(int(part) for part in declared.split("-")[0].split(".")) + assert core >= (0, 23, 1), f"plugin.yaml version {declared!r} is a downgrade below the 0.23.1 stable floor" + # And it must match the identity every other surface is synchronized to. + assert declared == IDENTITY_VERSION def test_issue_forms_stay_within_github_element_limit(): @@ -80,12 +97,14 @@ def test_preanswer_guide_discloses_inherited_embedding_provider_behavior(): def test_release_candidate_notes_cover_only_the_merged_release_scope(): notes = RELEASE_NOTES.read_text(encoding="utf-8") - - assert notes.startswith(f"# hermes-lcm v{RELEASE_VERSION}\n") - assert "#177" in notes - assert "## Highlights" in notes - assert "## Changes" in notes - assert "## Contributors" in notes - assert "occurrence-bounded" in notes - assert "DORMANT" in notes - assert len(notes.splitlines()) <= 60 + lines = notes.splitlines() + section_headers = [line for line in lines if line.startswith("## ")] + + assert notes.startswith(f"# v{RC_TAG} — ") + assert any(header.startswith("## Lossless by default:") for header in section_headers) + assert any(header.startswith("## Security:") for header in section_headers) + assert any(header.startswith("## Instrument integrity:") for header in section_headers) + assert any(header.startswith("## Release process:") for header in section_headers) + assert any(header.startswith("## Known follow-ups") for header in section_headers) + assert "## Benchmark boundary" in section_headers + assert 60 <= len(lines) <= 100