From 3592b92e04722e9e9fe7cb6633dfe64e17cd43f1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 7 Aug 2026 11:37:21 -0500 Subject: [PATCH] fix(lease): refuse an incomplete law binding instead of silently shrinking it 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. A partial law promotes exactly like a complete one and nothing downstream can tell the difference. Measured before this change, against a seeded home: with only USER.md readable, the client produced a one-fragment construction with promotion=True. Removing CONSTITUTION.md, STANDARDS.md or the runtime contract likewise promoted. The classification mirrors the framework's own file ownership rather than inventing one: * CONSTITUTION.md / AGENTS.md / STANDARDS.md are framework-owned and reconciled every upgrade (install.sh FRAMEWORK_OWNED, config/file-adapter.ts FRAMEWORK_OWNED_FILES), as is the per-runtime RUNTIME.md. Absent => IncompleteBinding. A deployment missing one is broken, not minimal. * SOUL.md / USER.md are deliberately not seeded by install.sh ("generated by `mosaic init`") and TOOLS.md is seeded on first install only, so their absence is legitimate. It is reported on stderr, never silent. Unreadable is handled 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. Also corrects this module's own docstring, which asserted that a VERIFIED lease means "this agent is running THIS law". It does not. Both sides of the broker's comparison originate in this client, so it detects corruption in transit and nothing else. That overstatement is where the belief spread from; the stronger claim needs the broker re-reading on-disk sources against a manifest the agent cannot rewrite. Test: promotion_binding_unittest.py, enumerated in test:framework-shell (the enumeration guard's population is *test*.sh and does not cover Python, so an unenumerated test here would simply never run). Falsifier executed: defeating the guard while leaving the module API intact turns the suite red (12 failures); restoring it returns green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EHYXhcCQsL3J1Lnm7EraGq --- .../tools/lease-broker/lease_promote.py | 86 ++++++++- packages/mosaic/package.json | 2 +- .../promotion_binding_unittest.py | 163 ++++++++++++++++++ 3 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 packages/mosaic/src/lease-broker/promotion_binding_unittest.py 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()