diff --git a/packages/mosaic/framework/tools/lease-broker/lease_promote.py b/packages/mosaic/framework/tools/lease-broker/lease_promote.py index 2a416b33..2588d8c5 100644 --- a/packages/mosaic/framework/tools/lease-broker/lease_promote.py +++ b/packages/mosaic/framework/tools/lease-broker/lease_promote.py @@ -31,13 +31,28 @@ while proving nothing — a gate-disabler indistinguishable from a working fix unless someone looks for it. **This module never posts a receipt.** Emitting it belongs to the runtime adapter, where a real model turn happens. -The construction binds the exact normative source bytes, so a VERIFIED lease -means "this agent is running THIS law", not merely "this session id is known". -``h_source`` / ``h_payload`` are derived by the framework's own +The construction binds the exact normative source bytes. ``h_source`` / +``h_payload`` are derived by the framework's own ``normative_fragments.build_payload`` rather than reimplemented: the broker derives them the same way and any divergence yields ``PAYLOAD_BINDING_MISMATCH``. There must be exactly one implementation. +WHAT THE BINDING DOES *NOT* PROVE +--------------------------------- +It is tempting to read a VERIFIED lease as "this agent is running THIS law". +**It does not mean that**, and writing it down that way is how the belief spread. +The broker holds no reference copy of any normative source and never opens one; +it recomputes ``h_source`` / ``h_payload`` from the fragment bytes THIS CLIENT +sent and compares them to the binding THIS CLIENT sent (``daemon.py:602-616``). +Both sides of that comparison originate here, so it detects corruption in +transit and nothing else. What the binding actually asserts is "the client +claims these bytes, self-consistently". + +Making it mean the stronger thing requires the broker to re-read the on-disk +sources itself, against a manifest the agent cannot rewrite — i.e. broker code +attestation under its own uid. Until then, do not cite a VERIFIED lease as +evidence of law integrity. + Usage ----- lease_promote.py --begin # prints the receipt the MODEL must emit @@ -48,6 +63,7 @@ from __future__ import annotations import argparse import base64 +import hashlib import json import os import socket @@ -70,8 +86,9 @@ MANIFEST_VERSION: Final = 1 GENERATOR_VERSION: Final = "mosaic/lease_promote@1" DEFAULT_TTL_SECONDS: Final = 300 -# Normative sources whose exact bytes bind the lease. Sources absent on a given -# deployment are simply not part of the binding — never fabricated. +# Normative sources whose exact bytes bind the lease, in binding order. Order is +# load-bearing: ``h_source`` frames the resolved sequence, so reordering changes +# the derivation. Never fabricate a source that is not on disk. FRAGMENT_SOURCES: Final = ( "CONSTITUTION.md", "AGENTS.md", @@ -81,6 +98,30 @@ FRAGMENT_SOURCES: Final = ( "TOOLS.md", ) +# Framework-owned sources, reconciled on every upgrade — `install.sh:76` +# FRAMEWORK_OWNED and `config/file-adapter.ts` FRAMEWORK_OWNED_FILES — plus the +# per-runtime contract shipped under `framework/runtime//`. A deployment +# missing one of these is broken, not minimal, so their absence is refused rather +# than silently dropped from the binding. +# +# SOUL.md and USER.md are deliberately excluded: install.sh does not seed them +# ("intentionally NOT seeded here — they are generated by `mosaic init`"), so a +# fresh install legitimately lacks both. TOOLS.md is user-seeded on first install +# only. Absence of those three is reported, not fatal. +REQUIRED_SOURCES: Final = frozenset({"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"}) + + +class IncompleteBinding(RuntimeError): + """A source that must bind this lease could not be read. + + **Never downgrade this to a skip.** The broker recomputes the hashes from the + fragments it is sent, so an omitted fragment is internally consistent and + ``PAYLOAD_BINDING_MISMATCH`` cannot fire — a partial law promotes exactly like + a complete one, and nothing downstream can tell the difference. Dropping an + unreadable source therefore does not degrade the binding, it forges a smaller + one. Fail here, where the omission is still visible. + """ + def mosaic_home() -> Path: return Path(os.environ.get("MOSAIC_HOME") or Path.home() / ".config" / "mosaic") @@ -116,16 +157,33 @@ def session_identity() -> tuple[str, int, str]: def build_construction(runtime: str) -> tuple[dict[str, object], object]: """Assemble the wire construction and derive its hashes with the sole builder.""" - sources = list(FRAGMENT_SOURCES) + [f"runtime/{runtime}/RUNTIME.md"] + runtime_contract = f"runtime/{runtime}/RUNTIME.md" + sources = list(FRAGMENT_SOURCES) + [runtime_contract] + required = REQUIRED_SOURCES | {runtime_contract} wire_fragments: list[dict[str, str]] = [] objects: list[NormativeFragment] = [] + absent: list[str] = [] for source_id in sources: try: content = (mosaic_home() / source_id).read_bytes() - except OSError: + except FileNotFoundError: + # Genuinely not on disk. Legitimate only for operator-owned sources. + if source_id in required: + raise IncompleteBinding( + f"required normative source is absent: {source_id}" + ) from None + absent.append(source_id) continue - import hashlib + except OSError as exc: + # The path resolves but will not read — EACCES, EIO, EISDIR, ELOOP. + # That is an anomaly for EVERY source, optional ones included: an + # unreadable file is not an un-configured one, and treating it as + # absent is what lets a permission change quietly shrink the law. + raise IncompleteBinding( + f"normative source is present but unreadable: {source_id} " + f"({type(exc).__name__})" + ) from exc digest = hashlib.sha256(content).hexdigest() wire_fragments.append( @@ -138,7 +196,17 @@ def build_construction(runtime: str) -> tuple[dict[str, object], object]: objects.append(NormativeFragment(source_id, content, digest)) if not wire_fragments: - raise RuntimeError("no normative sources found — refusing to build an empty binding") + raise IncompleteBinding("no normative sources found — refusing an empty binding") + + # Absence is legitimate here but never invisible. The omission is already + # baked into h_source (the framed source sequence differs), but nothing + # compares h_source to an expected value, so this line is the only place a + # human learns the binding was narrower than the full set. + if absent: + print( + f"lease_promote: binding omits absent operator sources: {', '.join(absent)}", + file=sys.stderr, + ) result = build_payload( manifest_version=MANIFEST_VERSION, diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 23234ef3..e3c4ba24 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*", diff --git a/packages/mosaic/src/lease-broker/promotion_binding_unittest.py b/packages/mosaic/src/lease-broker/promotion_binding_unittest.py new file mode 100644 index 00000000..e55c517a --- /dev/null +++ b/packages/mosaic/src/lease-broker/promotion_binding_unittest.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""The promotion client must never build a binding narrower than it claims. + +RED-first against a real defect: ``build_construction`` skipped any normative +source it could not read (``except OSError: continue``) and promoted whatever +remained. That is not a degraded binding, it is a forged smaller one — the +broker recomputes ``h_source`` / ``h_payload`` from the fragments it is *sent* +(``daemon.py:602-616``), so an omitted fragment is internally consistent and +``PAYLOAD_BINDING_MISMATCH`` cannot fire. Measured before the fix: with only +``USER.md`` readable (964 bytes on the live host), the client produced a +one-fragment construction with ``promotion=True``. + +The classification under test mirrors the framework's own file ownership, and +must keep mirroring it: + + * framework-owned, reconciled every upgrade (``install.sh`` FRAMEWORK_OWNED / + ``config/file-adapter.ts`` FRAMEWORK_OWNED_FILES) plus the per-runtime + contract — absence is a broken deployment, so it is REFUSED; + * ``SOUL.md`` / ``USER.md`` — install.sh deliberately does not seed them + ("generated by `mosaic init`"), so absence is legitimate and ALLOWED. + +Unreadable is treated separately from absent for *every* source, optional ones +included: a file that will not open is not a file that was never configured, and +collapsing the two is what let a permission change quietly shrink the law. +""" + +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path + +TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker" +sys.path.insert(0, str(TOOLS)) + +import lease_promote # noqa: E402 + +RUNTIME = "pi" +RUNTIME_CONTRACT = f"runtime/{RUNTIME}/RUNTIME.md" +ALL_SOURCES = (*lease_promote.FRAGMENT_SOURCES, RUNTIME_CONTRACT) +REQUIRED = frozenset(lease_promote.REQUIRED_SOURCES) | {RUNTIME_CONTRACT} +# Derived, never listed: a hand-kept second copy is exactly the drift this file +# exists to catch. +OPTIONAL = tuple(s for s in ALL_SOURCES if s not in REQUIRED) + + +class PromotionBindingTest(unittest.TestCase): + def setUp(self) -> None: + self._previous_home = os.environ.get("MOSAIC_HOME") + self._temporary = tempfile.TemporaryDirectory() + self.root = Path(self._temporary.name) + for source_id in ALL_SOURCES: + path = self.root / source_id + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(f"# {source_id}\nnormative bytes\n".encode()) + os.environ["MOSAIC_HOME"] = str(self.root) + + def tearDown(self) -> None: + for path in self.root.rglob("*"): + if path.is_file(): + path.chmod(0o644) + self._temporary.cleanup() + if self._previous_home is None: + os.environ.pop("MOSAIC_HOME", None) + else: + os.environ["MOSAIC_HOME"] = self._previous_home + + def reset_home(self) -> None: + """Discard the current home and seed a fresh complete one. + + Each subTest mutates the tree destructively, so it needs a clean start — + and the old one must be released, not orphaned. + """ + self.tearDown() + self.setUp() + + def build(self): + return lease_promote.build_construction(RUNTIME) + + def source_ids(self) -> list[str]: + construction, _ = self.build() + return [f["source_id"] for f in construction["fragments"]] + + # --- the binding is complete when the deployment is complete ------------- + + def test_complete_deployment_binds_every_source(self) -> None: + construction, result = self.build() + self.assertEqual([f["source_id"] for f in construction["fragments"]], list(ALL_SOURCES)) + self.assertTrue(result.promotion) + + # --- absence: refused for framework-owned, allowed for operator-owned ---- + + def test_absent_required_source_is_refused(self) -> None: + for source_id in sorted(REQUIRED): + with self.subTest(source=source_id): + self.reset_home() + (self.root / source_id).unlink() + with self.assertRaises(lease_promote.IncompleteBinding) as caught: + self.build() + self.assertIn(source_id, str(caught.exception)) + + def test_absent_operator_source_still_binds_the_rest(self) -> None: + for source_id in OPTIONAL: + with self.subTest(source=source_id): + self.reset_home() + (self.root / source_id).unlink() + bound = self.source_ids() + self.assertNotIn(source_id, bound) + for required in lease_promote.REQUIRED_SOURCES: + self.assertIn(required, bound) + + # --- unreadable is never the same as absent ----------------------------- + + def test_unreadable_source_is_refused_even_when_optional(self) -> None: + for source_id in ALL_SOURCES: + with self.subTest(source=source_id): + self.reset_home() + (self.root / source_id).chmod(0o000) + with self.assertRaises(lease_promote.IncompleteBinding) as caught: + self.build() + self.assertIn(source_id, str(caught.exception)) + + # --- the exact measured regression -------------------------------------- + + def test_single_readable_source_cannot_promote(self) -> None: + """The observed failure: only USER.md readable produced a valid binding.""" + for source_id in ALL_SOURCES: + if source_id != "USER.md": + (self.root / source_id).chmod(0o000) + with self.assertRaises(lease_promote.IncompleteBinding): + self.build() + + def test_no_source_readable_cannot_promote(self) -> None: + for source_id in ALL_SOURCES: + (self.root / source_id).chmod(0o000) + with self.assertRaises(lease_promote.IncompleteBinding): + self.build() + + # --- the classification must not drift from the framework's ------------- + + def test_required_set_excludes_only_the_unseeded_sources(self) -> None: + """`install.sh` decides which files exist; this list must follow it. + + If a source moves between framework-owned and operator-generated + upstream, this fails and forces the classification to be re-read rather + than silently inherited. + """ + self.assertEqual( + set(lease_promote.REQUIRED_SOURCES), + {"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"}, + "REQUIRED_SOURCES changed — re-read install.sh FRAMEWORK_OWNED and " + "config/file-adapter.ts FRAMEWORK_OWNED_FILES before accepting it", + ) + self.assertTrue( + set(lease_promote.REQUIRED_SOURCES) <= set(lease_promote.FRAGMENT_SOURCES), + "a required source is not in the binding order", + ) + + +if __name__ == "__main__": + unittest.main()